diff --git a/.angulardoc.json b/.angulardoc.json new file mode 100644 index 00000000..253388ca --- /dev/null +++ b/.angulardoc.json @@ -0,0 +1,4 @@ +{ + "repoId": "8f466ce7-4b75-4048-8b8a-cad5bf173aa0", + "lastSync": 0 +} \ No newline at end of file diff --git a/.ci-inject-internal-deps.sh b/.ci-inject-internal-deps.sh deleted file mode 100755 index 08304016..00000000 --- a/.ci-inject-internal-deps.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh - -DEP_FILE="Gopkg.toml" - -# remove ignored internal deps -sed -i '/ignored = \["github.com\/safing\//d' $DEP_FILE - -# portbase -PORTBASE_BRANCH="develop" -git branch | grep "* master" >/dev/null -if [ $? -eq 0 ]; then - PORTBASE_BRANCH="master" -fi -echo " -[[constraint]] - name = \"github.com/safing/portbase\" - branch = \"${PORTBASE_BRANCH}\" - -[[constraint]] - name = \"github.com/safing/spn\" - branch = \"${PORTBASE_BRANCH}\" -" >> $DEP_FILE diff --git a/.earthlyignore b/.earthlyignore new file mode 100644 index 00000000..fb7de17a --- /dev/null +++ b/.earthlyignore @@ -0,0 +1,63 @@ +# Ignore angular outputs. +desktop/angular/node_modules +desktop/angular/dist +desktop/angular/dist-lib +desktop/angular/dist-extension +desktop/angular/.angular + +# Ignore tauri outputs. +desktop/tauri/src-tauri/target + +####################### +# Copy from .gitignore: + +# Compiled binaries +*.exe +dist/ + +# Dist dir +dist + +# Custom dev deops +go.mod.* + +# vendor dir +vendor + +# testing +testing + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# OS specifics +.DS_Store + +# Custom dev scripts +win_dev_* +go.work +go.work.sum diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 12377170..f5d4a442 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,26 +3,18 @@ blank_issues_enabled: true # default: true contact_links: -# - name: "🛠 Create Issue for Portmaster" -# url: https://github.com/safing/portmaster/issues/new/choose -# about: "Create issue related to the main application" +- name: "Ask Questions on Discord" + url: https://safing.io/discord + about: Get help from our great community -- name: "🎨 Create Issue for User Interface" - url: https://github.com/safing/portmaster-ui/issues/new/choose - about: "Create issue for everything connected to the Portmaster's User Interface" - -- name: "📦 Create Issue for Packaging & Installers" - url: https://github.com/safing/portmaster-packaging/issues/new/choose - about: "Create issue for things related to Portmaster's installers, packaging and distribution" +- name: "Wiki and FAQ" + url: https://wiki.safing.io/ + about: Learn more about Portmaster in our Wiki - name: "Contribution Guideline" url: https://docs.safing.io/portmaster/guides/contribute about: Learn how to best contribute and make sure your work is aligned with Safing’s current goals and focus -- name: "Support Requests & Community" - url: https://www.reddit.com/r/safing - about: Ask for support and any questions you might have on reddit - - name: "Code of Conduct" url: https://docs.safing.io/community/code-of-conduct about: Be nice to other community members diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f6150ead..a7b6246d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,13 @@ updates: directory: "/" schedule: interval: "daily" + + - package-ecosystem: "npm" + directory: "/desktop/angular" + schedule: + interval: "daily" + + - package-ecosystem: "cargo" + directory: "/desktop/tauri" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/angular.yml b/.github/workflows/angular.yml new file mode 100644 index 00000000..7ca183b8 --- /dev/null +++ b/.github/workflows/angular.yml @@ -0,0 +1,59 @@ +name: Angular + +on: + push: + paths: + - 'desktop/angular/**' + branches: + - master + - develop + + pull_request: + paths: + - 'desktop/angular/**' + branches: + - master + - develop + +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + defaults: + run: + working-directory: desktop/angular + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version: 18 + + - run: npm install + + - uses: sibiraj-s/action-eslint@v3 + with: + annotations: true + extensions: 'ts,html' + working-directory: desktop/angular + + test: + name: Build + runs-on: ubuntu-latest + steps: + - uses: earthly/actions-setup@v1 + with: + version: v0.8.0 + - uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build angular projects + run: earthly --ci --remote-cache=ghcr.io/safing/build-cache --push +build-angular diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 823617ea..5a604e12 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,10 +2,23 @@ name: Go on: push: + paths: + - '**.go' + - 'cmds/**' + - 'runtime/**' + - 'service/**' + - 'spn/**' branches: - master - develop + pull_request: + paths: + - '**.go' + - 'cmds/**' + - 'runtime/**' + - 'service/**' + - 'spn/**' branches: - master - develop @@ -16,20 +29,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: '^1.21' - - - name: Get dependencies - run: go mod download + cache: false - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v4 with: - version: v1.52.2 + version: v1.57.1 only-new-issues: true args: -c ./.golangci.yml --timeout 15m @@ -40,16 +51,17 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Setup Go - uses: actions/setup-go@v4 + - uses: earthly/actions-setup@v1 with: - go-version: '^1.21' + version: v0.8.0 + - uses: actions/checkout@v4 - - name: Get dependencies - run: go mod download + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Run tests - run: ./test --test-only + - name: Run Go Tests + run: earthly --ci --remote-cache=ghcr.io/safing/build-cache --push +test-go diff --git a/.github/workflows/tauri.yml b/.github/workflows/tauri.yml new file mode 100644 index 00000000..edf804d2 --- /dev/null +++ b/.github/workflows/tauri.yml @@ -0,0 +1,36 @@ +name: Tauri + +on: + push: + paths: + - 'desktop/tauri/**' + branches: + - master + - develop + + pull_request: + paths: + - 'desktop/tauri/**' + branches: + - master + - develop + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: earthly/actions-setup@v1 + with: + version: v0.8.0 + - uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build tauri project + run: earthly --ci --remote-cache=ghcr.io/safing/build-cache --push +tauri-release diff --git a/.gitignore b/.gitignore index 88f8a650..8268448f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,6 @@ # Compiled binaries -portmaster -portmaster.exe -dnsonly -dnsonly.exe -main -main.exe -integrationtest -integrationtest.exe +*.exe +dist/ # Dist dir dist diff --git a/.golangci.yml b/.golangci.yml index 9893ff74..d6892cbc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -38,6 +38,9 @@ linters: - whitespace - wrapcheck - wsl + - perfsprint # TODO(ppacher): we should re-enanble this one to avoid costly fmt.* calls in the hot-path + - testifylint + - gomoddirectives linters-settings: revive: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/Earthfile b/Earthfile new file mode 100644 index 00000000..5acdb828 --- /dev/null +++ b/Earthfile @@ -0,0 +1,535 @@ +VERSION --arg-scope-and-set --global-cache 0.8 + +ARG --global go_version = 1.22 +ARG --global node_version = 18 +ARG --global rust_version = 1.76 + +ARG --global go_builder_image = "golang:${go_version}-alpine" +ARG --global node_builder_image = "node:${node_version}" +ARG --global rust_builder_image = "rust:${rust_version}-bookworm" +ARG --global work_image = "alpine" + +ARG --global outputDir = "./dist" + +# The list of rust targets we support. They will be automatically converted +# to GOOS, GOARCH and GOARM when building go binaries. See the +RUST_TO_GO_ARCH_STRING +# helper method at the bottom of the file. + + +ARG --global architectures = "x86_64-unknown-linux-gnu" \ + "aarch64-unknown-linux-gnu" \ + "x86_64-pc-windows-gnu" + +# Compile errors here: +# "armv7-unknown-linux-gnueabihf" \ +# "arm-unknown-linux-gnueabi" \ + +# Import the earthly rust lib since it already provides some useful +# build-targets and methods to initialize the rust toolchain. +IMPORT github.com/earthly/lib/rust:3.0.2 AS rust + +go-deps: + FROM ${go_builder_image} + WORKDIR /go-workdir + + # We need the git cli to extract version information for go-builds + RUN apk add git + + # These cache dirs will be used in later test and build targets + # to persist cached go packages. + # + # NOTE: cache only gets persisted on successful builds. A test + # failure will prevent the go cache from being persisted. + ENV GOCACHE = "/.go-cache" + ENV GOMODCACHE = "/.go-mod-cache" + + # Copying only go.mod and go.sum means that the cache for this + # target will only be busted when go.mod/go.sum change. This + # means that we can cache the results of 'go mod download'. + COPY go.mod . + COPY go.sum . + RUN go mod download + + # Explicitly cache here. + SAVE IMAGE --cache-hint + +go-base: + FROM +go-deps + + # Copy the full repo, as Go embeds whether the state is clean. + COPY . . + + LET version = "$(git tag --points-at || true)" + IF [ -z "${version}" ] + LET dev_version = "$(git describe --tags --first-parent --abbrev=0 || true)" + IF [ -n "${dev_version}" ] + SET version = "${dev_version}_dev_build" + END + END + IF [ -z "${version}" ] + SET version = "dev_build" + END + ENV VERSION="${version}" + RUN echo "Version: $VERSION" + + LET source = $( ( git remote -v | cut -f2 | cut -d" " -f1 | head -n 1 ) || echo "unknown" ) + ENV SOURCE="${source}" + RUN echo "Source: $SOURCE" + + LET build_time = $(date -u "+%Y-%m-%dT%H:%M:%SZ" || echo "unknown") + ENV BUILD_TIME = "${build_time}" + RUN echo "Build Time: $BUILD_TIME" + + # Explicitly cache here. + SAVE IMAGE --cache-hint + +# updates all go dependencies and runs go mod tidy, saving go.mod and go.sum locally. +update-go-deps: + FROM +go-base + + RUN go get -u ./.. + RUN go mod tidy + SAVE ARTIFACT go.mod AS LOCAL go.mod + SAVE ARTIFACT --if-exists go.sum AS LOCAL go.sum + +# mod-tidy runs 'go mod tidy', saving go.mod and go.sum locally. +mod-tidy: + FROM +go-base + + RUN go mod tidy + SAVE ARTIFACT go.mod AS LOCAL go.mod + SAVE ARTIFACT --if-exists go.sum AS LOCAL go.sum + +# build-go runs 'go build ./cmds/...', saving artifacts locally. +# If --CMDS is not set, it defaults to building portmaster-start, portmaster-core and hub +build-go: + FROM +go-base + + # Arguments for cross-compilation. + ARG GOOS=linux + ARG GOARCH=amd64 + ARG GOARM + ARG CMDS=portmaster-start portmaster-core hub notifier + + CACHE --sharing shared "$GOCACHE" + CACHE --sharing shared "$GOMODCACHE" + + RUN mkdir /tmp/build + ENV CGO_ENABLED = "0" + + IF [ "${CMDS}" = "" ] + LET CMDS=$(ls -1 "./cmds/") + END + + # Build all go binaries from the specified in CMDS + FOR bin IN $CMDS + RUN --no-cache go build -ldflags="-X github.com/safing/portbase/info.version=${VERSION} -X github.com/safing/portbase/info.buildSource=${SOURCE} -X github.com/safing/portbase/info.buildTime=${BUILD_TIME}" -o "/tmp/build/" ./cmds/${bin} + END + + DO +GO_ARCH_STRING --goos="${GOOS}" --goarch="${GOARCH}" --goarm="${GOARM}" + + FOR bin IN $(ls -1 "/tmp/build/") + SAVE ARTIFACT "/tmp/build/${bin}" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/${bin}" + END + + SAVE ARTIFACT "/tmp/build/" ./output + +# Test one or more go packages. +# Test are always run as -short, as "long" tests require a full desktop system. +# Run `earthly +test-go` to test all packages +# Run `earthly +test-go --PKG="service/firewall"` to only test a specific package. +# Run `earthly +test-go --TESTFLAGS="-args arg1"` to add custom flags to go test (-args in this case) +test-go: + FROM +go-base + + ARG GOOS=linux + ARG GOARCH=amd64 + ARG GOARM + ARG TESTFLAGS + ARG PKG="..." + + CACHE --sharing shared "$GOCACHE" + CACHE --sharing shared "$GOMODCACHE" + + FOR pkg IN $(go list -e "./${PKG}") + RUN --no-cache go test -cover -short ${pkg} ${TESTFLAGS} + END + +test-go-all-platforms: + FROM ${work_image} + + FOR arch IN ${architectures} + DO +RUST_TO_GO_ARCH_STRING --rustTarget="${arch}" + BUILD +test-go --GOARCH="${GOARCH}" --GOOS="${GOOS}" --GOARM="${GOARM}" + END + +# Builds portmaster-start, portmaster-core, hub and notifier for all supported platforms +build-go-release: + FROM ${work_image} + + FOR arch IN ${architectures} + DO +RUST_TO_GO_ARCH_STRING --rustTarget="${arch}" + + IF [ -z GOARCH ] + RUN echo "Failed to extract GOARCH for ${arch}"; exit 1 + END + + IF [ -z GOOS ] + RUN echo "Failed to extract GOOS for ${arch}"; exit 1 + END + + BUILD +build-go --GOARCH="${GOARCH}" --GOOS="${GOOS}" --GOARM="${GOARM}" + END + +# Builds all binaries from the cmds/ folder for linux/windows AMD64 +# Most utility binaries are never needed on other platforms. +build-utils: + BUILD +build-go --CMDS="" --GOARCH=amd64 --GOOS=linux + BUILD +build-go --CMDS="" --GOARCH=amd64 --GOOS=windows + +# Prepares the angular project by installing dependencies +angular-deps: + FROM ${node_builder_image} + WORKDIR /app/ui + + RUN apt update && apt install zip + + COPY desktop/angular/package.json . + COPY desktop/angular/package-lock.json . + + RUN npm install + +# Copies the UI folder into the working container +# and builds the shared libraries in the specified configuration (production or development) +angular-base: + FROM +angular-deps + ARG configuration="production" + + COPY desktop/angular/ . + # Remove symlink and copy assets directly. + RUN rm ./assets + COPY assets/data ./assets + + IF [ "${configuration}" = "production" ] + RUN npm run build-libs + ELSE + RUN npm run build-libs:dev + END + + # Explicitly cache here. + SAVE IMAGE --cache-hint + +# Build an angualr project, zip it and save artifacts locally +angular-project: + ARG --required project + ARG --required dist + ARG configuration="production" + ARG baseHref="/" + + FROM +angular-base --configuration="${configuration}" + + IF [ "${configuration}" = "production" ] + ENV NODE_ENV="production" + END + + RUN --no-cache ./node_modules/.bin/ng build --configuration ${configuration} --base-href ${baseHref} "${project}" + + RUN --no-cache cwd=$(pwd) && cd "${dist}" && zip -r "${cwd}/${project}.zip" ./ + SAVE ARTIFACT "${dist}" "./output/${project}" + + # Save portmaster UI as local artifact. + IF [ "${project}" = "portmaster" ] + SAVE ARTIFACT "./${project}.zip" AS LOCAL ${outputDir}/all/${project}-ui.zip + END + +# Build the angular projects (portmaster-UI and tauri-builtin) in dev mode +angular-dev: + BUILD +angular-project --project=portmaster --dist=./dist --configuration=development --baseHref=/ui/modules/portmaster/ + BUILD +angular-project --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=development --baseHref=/ + +# Build the angular projects (portmaster-UI and tauri-builtin) in production mode +angular-release: + BUILD +angular-project --project=portmaster --dist=./dist --configuration=production --baseHref=/ui/modules/portmaster/ + BUILD +angular-project --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=production --baseHref=/ + +# A base target for rust to prepare the build container +rust-base: + FROM ${rust_builder_image} + + RUN dpkg --add-architecture armhf + RUN dpkg --add-architecture arm64 + + RUN apt-get update -qq + + # Tools and libraries required for cross-compilation + RUN apt-get install --no-install-recommends -qq \ + autoconf \ + autotools-dev \ + libtool-bin \ + clang \ + cmake \ + bsdmainutils \ + gcc-multilib \ + linux-libc-dev \ + linux-libc-dev-amd64-cross \ + linux-libc-dev-arm64-cross \ + linux-libc-dev-armel-cross \ + linux-libc-dev-armhf-cross \ + build-essential \ + curl \ + wget \ + file \ + libsoup-3.0-dev \ + libwebkit2gtk-4.1-dev + + # Install library dependencies for all supported architectures + # required for succesfully linking. + FOR arch IN amd64 arm64 armhf + RUN apt-get install --no-install-recommends -qq \ + libsoup-3.0-0:${arch} \ + libwebkit2gtk-4.1-0:${arch} \ + libssl3:${arch} \ + libayatana-appindicator3-1:${arch} \ + librsvg2-bin:${arch} \ + libgtk-3-0:${arch} \ + libjavascriptcoregtk-4.1-0:${arch} \ + libssl-dev:${arch} \ + libayatana-appindicator3-dev:${arch} \ + librsvg2-dev:${arch} \ + libgtk-3-dev:${arch} \ + libjavascriptcoregtk-4.1-dev:${arch} + END + + # Note(ppacher): I've no idea why we need to explicitly create those symlinks: + # Some how all the other libs work but libsoup and libwebkit2gtk do not create the link file + RUN cd /usr/lib/aarch64-linux-gnu && \ + ln -s libwebkit2gtk-4.1.so.0 libwebkit2gtk-4.1.so && \ + ln -s libsoup-3.0.so.0 libsoup-3.0.so + + RUN cd /usr/lib/arm-linux-gnueabihf && \ + ln -s libwebkit2gtk-4.1.so.0 libwebkit2gtk-4.1.so && \ + ln -s libsoup-3.0.so.0 libsoup-3.0.so + + # For what ever reason trying to install the gcc compilers together with the above + # command makes apt fail due to conflicts with gcc-multilib. Installing in a separate + # step seems to work ... + RUN apt-get install --no-install-recommends -qq \ + g++-mingw-w64-x86-64 \ + gcc-aarch64-linux-gnu \ + gcc-arm-none-eabi \ + gcc-arm-linux-gnueabi \ + gcc-arm-linux-gnueabihf \ + libc6-dev-arm64-cross \ + libc6-dev-armel-cross \ + libc6-dev-armhf-cross \ + libc6-dev-amd64-cross + + # Add some required rustup components + RUN rustup component add clippy + RUN rustup component add rustfmt + + # Install architecture targets + FOR arch IN ${architectures} + RUN rustup target add ${arch} + END + + DO rust+INIT --keep_fingerprints=true + + # For now we need tauri-cli 1.5 for bulding + DO rust+CARGO --args="install tauri-cli --version ^1.5.11" + + # Required for cross compilation to work. + ENV PKG_CONFIG_ALLOW_CROSS=1 + + # Explicitly cache here. + SAVE IMAGE --cache-hint + +tauri-src: + FROM +rust-base + + WORKDIR /app/tauri + + # --keep-ts is necessary to ensure that the timestamps of the source files + # are preserved such that Rust's incremental compilation works correctly. + COPY --keep-ts ./desktop/tauri/ . + COPY assets/data ./assets + COPY packaging/linux ./../../packaging/linux + COPY (+angular-project/output/tauri-builtin --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=production --baseHref="/") ./../angular/dist/tauri-builtin + + WORKDIR /app/tauri/src-tauri + + # Explicitly cache here. + SAVE IMAGE --cache-hint + +build-tauri: + FROM +tauri-src + + ARG --required target + ARG output=".*/release/(([^\./]+|([^\./]+\.(dll|exe)))|bundle/.*\.(deb|msi|AppImage))" + ARG bundle="none" + + + # if we want tauri to create the installer bundles we also need to provide all external binaries + # we need to do some magic here because tauri expects the binaries to include the rust target tripple. + # We already knwo that triple because it's a required argument. From that triple, we use +RUST_TO_GO_ARCH_STRING + # function from below to parse the triple and guess wich GOOS and GOARCH we need. + RUN mkdir /tmp/gobuild + RUN mkdir ./binaries + + DO +RUST_TO_GO_ARCH_STRING --rustTarget="${target}" + RUN echo "GOOS=${GOOS} GOARCH=${GOARCH} GOARM=${GOARM} GO_ARCH_STRING=${GO_ARCH_STRING}" + + # Our tauri app has externalBins configured so tauri will try to embed them when it finished compiling + # the app. Make sure we copy portmaster-start and portmaster-core in all architectures supported. + # See documentation for externalBins for more information on how tauri searches for the binaries. + + COPY (+build-go/output --GOOS="${GOOS}" --CMDS="portmaster-start portmaster-core" --GOARCH="${GOARCH}" --GOARM="${GOARM}") /tmp/gobuild + + # Place them in the correct folder with the rust target tripple attached. + FOR bin IN $(ls /tmp/gobuild) + # ${bin$.*} does not work in SET commands unfortunately so we use a shell + # snippet here: + RUN set -e ; \ + dest="./binaries/${bin}-${target}" ; \ + if [ -z "${bin##*.exe}" ]; then \ + dest="./binaries/${bin%.*}-${target}.exe" ; \ + fi ; \ + cp "/tmp/gobuild/${bin}" "${dest}" ; + END + + # Just for debugging ... + RUN ls -R ./binaries + + # The following is exected to work but doesn't. for whatever reason cargo-sweep errors out on the windows-toolchain. + # + # DO rust+CARGO --args="tauri build --bundles none --ci --target=${target}" --output="release/[^/\.]+" + # + # For, now, we just directly mount the rust target cache and call cargo ourself. + + DO rust+SET_CACHE_MOUNTS_ENV + RUN --mount=$EARTHLY_RUST_TARGET_CACHE cargo tauri build --bundles "${bundle}" --ci --target="${target}" + DO rust+COPY_OUTPUT --output="${output}" + + # BUG(cross-compilation): + # + # The above command seems to correctly compile for all architectures we want to support but fails during + # linking since the target libaries are not available for the requested platforms. Maybe we need to download + # the, manually ... + # + # The earthly rust lib also has support for using cross-rs for cross-compilation but that fails due to the + # fact that cross-rs base docker images used for building are heavily outdated (latest = ubunut:16.0, main = ubuntu:20.04) + # which does not ship recent enough glib versions (our glib dependency needs glib>2.70 but ubunut:20.04 only ships 2.64) + # + # The following would use the CROSS function from the earthly lib, this + # DO rust+CROSS --target="${target}" + + # RUN echo output: $(ls "target/${target}/release") + LET outbin="error" + FOR bin IN "portmaster Portmaster.exe WebView2Loader.dll" + # Modify output binary. + SET outbin="${bin}" + IF [ "${bin}" = "portmaster" ] + SET outbin="portmaster-app" + ELSE IF [ "${bin}" = "Portmaster.exe" ] + SET outbin="portmaster-app.exe" + END + # Save output binary as local artifact. + IF [ -f "target/${target}/release/${bin}" ] + SAVE ARTIFACT "target/${target}/release/${bin}" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/${outbin}" + END + END + +tauri-release: + FROM ${work_image} + + ARG bundle="none" + + FOR arch IN ${architectures} + BUILD +build-tauri --target="${arch}" --bundle="${bundle}" + END + +build-all: + BUILD +build-go-release + BUILD +angular-release + BUILD +tauri-release + +release: + LOCALLY + + IF ! git diff --quiet + RUN echo -e "\033[1;31m Refusing to release a dirty git repository. Please commit your local changes first! \033[0m" ; exit 1 + END + + BUILD +build-all + + +# Takes GOOS, GOARCH and optionally GOARM and creates a string representation for file-names. +# in the form of ${GOOS}_{GOARCH} if GOARM is empty, otherwise ${GOOS}_${GOARCH}v${GOARM}. +# Thats the same format as expected and served by our update server. +# +# The result is available as GO_ARCH_STRING environment variable in the build context. +GO_ARCH_STRING: + FUNCTION + ARG --required goos + ARG --required goarch + ARG goarm + + LET result = "${goos}_${goarch}" + IF [ "${goarm}" != "" ] + SET result = "${goos}_${goarch}v${goarm}" + END + + ENV GO_ARCH_STRING="${result}" + +# Takes a rust target (--rustTarget) and extracts architecture and OS and arm version +# and finally calls GO_ARCH_STRING. +# +# The result is available as GO_ARCH_STRING environment variable in the build context. +# It also exports GOOS, GOARCH and GOARM environment variables. +RUST_TO_GO_ARCH_STRING: + FUNCTION + ARG --required rustTarget + + LET goos="" + IF [ -z "${rustTarget##*linux*}" ] + SET goos="linux" + ELSE + SET goos="windows" + END + + + LET goarch="" + LET goarm="" + + IF [ -z "${rustTarget##*x86_64*}" ] + SET goarch="amd64" + ELSE IF [ -z "${rustTarget##*arm*}" ] + SET goarch="arm" + SET goarm="6" + + IF [ -z "${rustTarget##*v7*}" ] + SET goarm="7" + END + ELSE IF [ -z "${rustTarget##*aarch64*}" ] + SET goarch="arm64" + ELSE + RUN echo "GOARCH not detected"; \ + exit 1; + END + + ENV GOOS="${goos}" + ENV GOARCH="${goarch}" + ENV GOARM="${goarm}" + + DO +GO_ARCH_STRING --goos="${goos}" --goarch="${goarch}" --goarm="${goarm}" + +# Takes an architecture or GOOS string and sets the BINEXT env var. +BIN_EXT: + FUNCTION + ARG --required arch + + LET binext="" + IF [ -z "${arch##*windows*}" ] + SET binext=".exe" + END + ENV BINEXT="${goos}" diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index ebf28930..00000000 --- a/Gopkg.lock +++ /dev/null @@ -1,405 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - digest = "1:6146fda730c18186631e91e818d995e759e7cbe27644d6871ccd469f6865c686" - name = "github.com/StackExchange/wmi" - packages = ["."] - pruneopts = "" - revision = "cbe66965904dbe8a6cd589e2298e5d8b986bd7dd" - version = "1.1.0" - -[[projects]] - digest = "1:e010d6b45ee6c721df761eae89961c634ceb55feff166a48d15504729309f267" - name = "github.com/TheTannerRyan/ring" - packages = ["."] - pruneopts = "" - revision = "7b27005873e31b5d5a035e166636a09e03aaf40e" - version = "v1.1.1" - -[[projects]] - digest = "1:21caed545a1c7ef7a2627bbb45989f689872ff6d5087d49c31340ce74c36de59" - name = "github.com/agext/levenshtein" - packages = ["."] - pruneopts = "" - revision = "52c14c47d03211d8ac1834e94601635e07c5a6ef" - version = "v1.2.3" - -[[projects]] - branch = "v2.1" - digest = "1:3fc5d0d9cb474736e8e6c2f2292e0763b5132c6e7d8cbedf7bde404a470c8c3b" - name = "github.com/cookieo9/resources-go" - packages = ["."] - pruneopts = "" - revision = "d27c04069d0d5dfe11c202dacbf745ae8d1ab181" - -[[projects]] - digest = "1:f384a8b6f89c502229e9013aa4f89ce5b5b56f09f9a4d601d7f1f026d3564fbf" - name = "github.com/coreos/go-iptables" - packages = ["iptables"] - pruneopts = "" - revision = "f901d6c2a4f2a4df092b98c33366dfba1f93d7a0" - version = "v0.4.5" - -[[projects]] - digest = "1:0deddd908b6b4b768cfc272c16ee61e7088a60f7fe2f06c547bd3d8e1f8b8e77" - name = "github.com/davecgh/go-spew" - packages = ["spew"] - pruneopts = "" - revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" - version = "v1.1.1" - -[[projects]] - branch = "master" - digest = "1:c8098f53cd182561cfb128c9a5ba70e41ad2364b763f33f05c6bd54003ae6495" - name = "github.com/florianl/go-nfqueue" - packages = [ - ".", - "internal/unix", - ] - pruneopts = "" - revision = "a2f196e98ab0ffdcb8b5252e7cbba98e45dea204" - -[[projects]] - digest = "1:b6581f9180e0f2d5549280d71819ab951db9d511478c87daca95669589d505c0" - name = "github.com/go-ole/go-ole" - packages = [ - ".", - "oleutil", - ] - pruneopts = "" - revision = "97b6244175ae18ea6eef668034fd6565847501c9" - version = "v1.2.4" - -[[projects]] - digest = "1:f63933986e63230fc32512ed00bc18ea4dbb0f57b5da18561314928fd20c2ff0" - name = "github.com/godbus/dbus" - packages = ["."] - pruneopts = "" - revision = "37bf87eef99d69c4f1d3528bd66e3a87dc201472" - version = "v5.0.3" - -[[projects]] - digest = "1:c18de9c9afca0ab336a29cf356d566abbdc29dd4948547557ed62c0da30d3be3" - name = "github.com/google/gopacket" - packages = [ - ".", - "layers", - "tcpassembly", - ] - pruneopts = "" - revision = "558173e197d46ae52f0f7c58313c96296ee16a9c" - version = "v1.1.18" - -[[projects]] - digest = "1:20dc576ad8f98fe64777c62f090a9b37dd67c62b23fe42b429c2c41936aa8a9c" - name = "github.com/google/renameio" - packages = ["."] - pruneopts = "" - revision = "f0e32980c006571efd537032e5f9cd8c1a92819e" - version = "v0.1.0" - -[[projects]] - digest = "1:8e3bd93036b4a925fe2250d3e4f38f21cadb8ef623561cd80c3c50c114b13201" - name = "github.com/hashicorp/errwrap" - packages = ["."] - pruneopts = "" - revision = "8a6fb523712970c966eefc6b39ed2c5e74880354" - version = "v1.0.0" - -[[projects]] - digest = "1:c6e569ffa34fcd24febd3562bff0520a104d15d1a600199cb3141debf2e58c89" - name = "github.com/hashicorp/go-multierror" - packages = ["."] - pruneopts = "" - revision = "2004d9dba6b07a5b8d133209244f376680f9d472" - version = "v1.1.0" - -[[projects]] - digest = "1:ebffb4b4c8ddcf66bb549464183ea2ddbac6c58a803658f67249f83395d17455" - name = "github.com/hashicorp/go-version" - packages = ["."] - pruneopts = "" - revision = "59da58cfd357de719a4d16dac30481391a56c002" - version = "v1.2.1" - -[[projects]] - digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" - name = "github.com/inconshreveable/mousetrap" - packages = ["."] - pruneopts = "" - revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" - version = "v1.0" - -[[projects]] - branch = "master" - digest = "1:e71cc6b377264002aec0d9c235087e51ad7a3c1fb341bb4baa84709308b94fe8" - name = "github.com/kardianos/osext" - packages = ["."] - pruneopts = "" - revision = "2bc1f35cddc0cc527b4bc3dce8578fc2a6c11384" - -[[projects]] - digest = "1:711ec17a2d8edd94cff8e2e4339d847e46acc1bb6b49ec29dcc1db78b666378b" - name = "github.com/mdlayher/netlink" - packages = [ - ".", - "nlenc", - ] - pruneopts = "" - revision = "2a4e26491f1ba4eae173a7733ac11744cfed82b5" - version = "v1.2.0" - -[[projects]] - digest = "1:508f444b8e00a569a40899aaf5740348b44c305d36f36d4f002b277677deef95" - name = "github.com/miekg/dns" - packages = ["."] - pruneopts = "" - revision = "10e0aeedbee54849adab780611454192a9980443" - version = "v1.1.33" - -[[projects]] - digest = "1:3282ac9a9ddf5c2c0eda96693364d34fe0f8d10a0748259082a5c9fbd3e1f7e4" - name = "github.com/oschwald/maxminddb-golang" - packages = ["."] - pruneopts = "" - revision = "2e4624cc0c4105b1df1d0643ac3aadb53824dc7d" - version = "v1.7.0" - -[[projects]] - digest = "1:c45802472e0c06928cd997661f2af610accd85217023b1d5f6331bebce0671d3" - name = "github.com/pkg/errors" - packages = ["."] - pruneopts = "" - revision = "614d223910a179a466c1767a985424175c39b465" - version = "v0.9.1" - -[[projects]] - digest = "1:256484dbbcd271f9ecebc6795b2df8cad4c458dd0f5fd82a8c2fa0c29f233411" - name = "github.com/pmezard/go-difflib" - packages = ["difflib"] - pruneopts = "" - revision = "792786c7400a136282c1664665ae0a8db921c6c2" - version = "v1.0.0" - -[[projects]] - digest = "1:70e15b4090e254d1eada6ef156773c0888cf707c43078479114d814761b902c5" - name = "github.com/shirou/gopsutil" - packages = [ - "cpu", - "internal/common", - "mem", - "net", - "process", - ] - pruneopts = "" - revision = "7e94bb8bcde053b6d6c98bda5145e9742c913c39" - version = "v2.20.7" - -[[projects]] - digest = "1:bff75d4f1a2d2c4b8f4b46ff5ac230b80b5fa49276f615900cba09fe4c97e66e" - name = "github.com/spf13/cobra" - packages = ["."] - pruneopts = "" - revision = "a684a6d7f5e37385d954dd3b5a14fc6912c6ab9d" - version = "v1.0.0" - -[[projects]] - digest = "1:688428eeb1ca80d92599eb3254bdf91b51d7e232fead3a73844c1f201a281e51" - name = "github.com/spf13/pflag" - packages = ["."] - pruneopts = "" - revision = "2e9d26c8c37aae03e3f9d4e90b7116f5accb7cab" - version = "v1.0.5" - -[[projects]] - digest = "1:83fd2513b9f6ae0997bf646db6b74e9e00131e31002116fda597175f25add42d" - name = "github.com/stretchr/testify" - packages = ["assert"] - pruneopts = "" - revision = "f654a9112bbeac49ca2cd45bfbe11533c4666cf8" - version = "v1.6.1" - -[[projects]] - digest = "1:1f11a269b089908c141f78c060991ff7bcd16545e95ee48d557e638fa846bde2" - name = "github.com/tevino/abool" - packages = ["."] - pruneopts = "" - revision = "8ae5c93531aabf12924a5b78e6dee1216bfff2f8" - version = "v1.2.0" - -[[projects]] - branch = "master" - digest = "1:21097653bd7914de1262f2429e277933507442f892815a791ce1c0dbf0a8dc20" - name = "github.com/umahmood/haversine" - packages = ["."] - pruneopts = "" - revision = "808ab04add26660fd241ddb7973886c6dd6669e8" - -[[projects]] - branch = "master" - digest = "1:df4642a605244e62c69ae335ac3c3cfa1c2b7ec971c3de398e1909592a961923" - name = "golang.org/x/crypto" - packages = [ - "ed25519", - "ed25519/internal/edwards25519", - ] - pruneopts = "" - revision = "123391ffb6de907695e1066dc40c1ff09322aeb6" - -[[projects]] - digest = "1:ba49944a3238ae8f163c85b6d01d2db51cd5b09807105a3cfaacbd414744ca82" - name = "golang.org/x/mod" - packages = ["semver"] - pruneopts = "" - revision = "859b3ef565e237f9f1a0fb6b55385c497545680d" - version = "v0.3.0" - -[[projects]] - branch = "master" - digest = "1:9ee0e6bc20d85d179d19be321443639dc501a8c0ba1bac173261b57768063e79" - name = "golang.org/x/net" - packages = [ - "bpf", - "icmp", - "idna", - "internal/iana", - "internal/socket", - "ipv4", - "ipv6", - "publicsuffix", - ] - pruneopts = "" - revision = "3edf25e44fccea9e11b919341e952fca722ef460" - -[[projects]] - branch = "master" - digest = "1:ae1578a64c2b241c13ab243739d05936d83825d2b6e9ff043ea3c7105666493d" - name = "golang.org/x/sync" - packages = [ - "errgroup", - "singleflight", - ] - pruneopts = "" - revision = "6e8e738ad208923de99951fe0b48239bfd864f28" - -[[projects]] - branch = "master" - digest = "1:ecfcd51736bf55de713770df4580026a43f01a94c9c077b0ab10239e8a93a589" - name = "golang.org/x/sys" - packages = [ - "internal/unsafeheader", - "unix", - "windows", - "windows/registry", - "windows/svc", - "windows/svc/debug", - "windows/svc/eventlog", - "windows/svc/mgr", - ] - pruneopts = "" - revision = "3ff754bf58a9922e2b8a1a0bd199be6c9a806123" - -[[projects]] - digest = "1:fccda34e4c58111b1908d8d69bf8d57c41c8e2542bc18ec8cd38c4fa21057f71" - name = "golang.org/x/text" - packages = [ - "collate", - "collate/build", - "internal/colltab", - "internal/gen", - "internal/language", - "internal/language/compact", - "internal/tag", - "internal/triegen", - "internal/ucd", - "language", - "secure/bidirule", - "transform", - "unicode/bidi", - "unicode/cldr", - "unicode/norm", - "unicode/rangetable", - ] - pruneopts = "" - revision = "23ae387dee1f90d29a23c0e87ee0b46038fbed0e" - version = "v0.3.3" - -[[projects]] - branch = "master" - digest = "1:1f61b0af124800c576e5ccc355d0634413e0b71fe6fbc77694b18bd30d9aa56e" - name = "golang.org/x/tools" - packages = [ - "go/ast/astutil", - "go/gcexportdata", - "go/internal/gcimporter", - "go/internal/packagesdriver", - "go/packages", - "go/types/typeutil", - "internal/event", - "internal/event/core", - "internal/event/keys", - "internal/event/label", - "internal/gocommand", - "internal/packagesinternal", - "internal/typesinternal", - ] - pruneopts = "" - revision = "d00afeaade8f1e68fb815705aa42d704c1b6df35" - -[[projects]] - branch = "master" - digest = "1:a5a7a1a9560c0eb1f8b32c40da2e71bd2a05b9ff9e1ea294461c7dbe0d24c6bc" - name = "golang.org/x/xerrors" - packages = [ - ".", - "internal", - ] - pruneopts = "" - revision = "5ec99f83aff198f5fbd629d6c8d8eb38a04218ca" - -[[projects]] - branch = "v3" - digest = "1:2e9c4d6def1d36dcd17730e00c06b49a2e97ea5e1e639bcd24fa60fa43e33ad6" - name = "gopkg.in/yaml.v3" - packages = ["."] - pruneopts = "" - revision = "eeeca48fe7764f320e4870d231902bf9c1be2c08" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = [ - "github.com/TheTannerRyan/ring", - "github.com/agext/levenshtein", - "github.com/cookieo9/resources-go", - "github.com/coreos/go-iptables/iptables", - "github.com/florianl/go-nfqueue", - "github.com/godbus/dbus", - "github.com/google/gopacket", - "github.com/google/gopacket/layers", - "github.com/google/gopacket/tcpassembly", - "github.com/google/renameio", - "github.com/hashicorp/go-multierror", - "github.com/hashicorp/go-version", - "github.com/miekg/dns", - "github.com/oschwald/maxminddb-golang", - "github.com/shirou/gopsutil/process", - "github.com/spf13/cobra", - "github.com/stretchr/testify/assert", - "github.com/tevino/abool", - "github.com/umahmood/haversine", - "golang.org/x/net/icmp", - "golang.org/x/net/ipv4", - "golang.org/x/net/publicsuffix", - "golang.org/x/sync/errgroup", - "golang.org/x/sync/singleflight", - "golang.org/x/sys/unix", - "golang.org/x/sys/windows", - "golang.org/x/sys/windows/svc", - "golang.org/x/sys/windows/svc/debug", - "golang.org/x/sys/windows/svc/eventlog", - "golang.org/x/sys/windows/svc/mgr", - ] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 80648950..00000000 --- a/Gopkg.toml +++ /dev/null @@ -1,35 +0,0 @@ -# Gopkg.toml example -# -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - -ignored = ["github.com/safing/portbase/*", "github.com/safing/spn/*"] - -[[constraint]] - name = "github.com/florianl/go-nfqueue" - branch = "master" # switch back once we migrate to go.mod - -[[override]] - name = "github.com/mdlayher/netlink" - version = "1.2.0" # remove when github.com/florianl/go-nfqueue has updated to v1.2.0 diff --git a/LICENSE b/LICENSE index 0ad25db4..f288702d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,15 +7,17 @@ Preamble - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to +the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. @@ -60,7 +72,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU Affero General Public License. + "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. + 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single +under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General +Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published +GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's +versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. + GNU General Public License for more details. - You should have received a copy of the GNU Affero General Public License + You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see +For more information on this, and how to apply and follow the GNU GPL, see . + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index ddb5c737..81498a10 100644 --- a/README.md +++ b/README.md @@ -5,37 +5,32 @@ Restore privacy and take back control over all your computer's network activity. With great defaults your privacy improves without any effort. And if you want to configure and control everything down to the last detail - Portmaster has you covered too. Developed in the EU 🇪🇺, Austria. +__[Download for Free](https://safing.io/download/)__ + +__[About Us](https://safing.io/about/)__ + ![Portmaster User Interface](https://safing.io/assets/img/page-specific/landing/portmaster-thumbnail.png?) -## Features +_seen on:_ -1. [Monitor All Network Activity](https://safing.io/features#monitor-all-network-activity) -2. [Automatically Block Trackers & Malware](https://safing.io/features#auto-block-trackers-and-malware) -3. [Secure Your DNS Requests by Default](https://safing.io/features#secure-dns-by-default) -4. [Create Your Own Rules](https://safing.io/features#create-your-own-rules) -5. [Set Global & per‑App Settings](https://safing.io/features#set-global-and-app-settings) -6. [FAQ](https://wiki.safing.io/en/FAQ/FrequentlyAskedQuestions) - -# [Download for Free](https://safing.io/download/) - -## About Safing - -- [About](https://safing.io/about/) -- [Pricing](https://safing.io/pricing/) -- [Business Model](https://safing.io/business-model/) -- [Ownership](https://safing.io/ownership/) -- [Team](https://safing.io/team/) - -## As Seen on: - -[![It's FOSS](https://safing.io/assets/img//external/itsfoss.png)](https://news.itsfoss.com/portmaster-1-release/) -        -[![ghacks.net](https://safing.io/assets/img//external/ghacks.png)](https://www.ghacks.net/2022/11/08/portmaster-1-0-released-open-source-application-firewall/) +[](https://www.heise.de/tests/Datenschutz-Firewall-Portmaster-im-Test-9611687.html)     -[![Techlore](https://safing.io/assets/img//external/techlore.png)](https://www.youtube.com/watch?v=E8cTRhGtmcM) +[![ghacks.net](https://safing.io/assets/img/external/ghacks.png)](https://www.ghacks.net/2022/11/08/portmaster-1-0-released-open-source-application-firewall/) +    +[![Techlore](https://safing.io/assets/img/external/techlore.png)](https://www.youtube.com/watch?v=E8cTRhGtmcM)     [![Lifehacker](https://safing.io/assets/img/external/logos/lifehacker.webp)](https://lifehacker.com/the-lesser-known-apps-everyone-should-install-on-a-new-1850223434) +## [Features](https://safing.io/features/) + +1. Monitor All Network Activity +2. Full Control: Block Anything +3. Automatically Block Trackers & Malware +4. Set Global & Per‑App Settings +5. Secure DNS (Doh/DoT) +6. Record and Search Network Activity ([$](https://safing.io/pricing/)) +7. Per-App Bandwidth Usage ([$](https://safing.io/pricing/)) +8. [SPN, our Next-Gen Privacy Network](https://safing.io/spn/) ([$$](https://safing.io/pricing/)) # Technical Introduction @@ -77,7 +72,7 @@ Portmaster is a privacy suite for your Windows and Linux desktop. - Monitor bandwidth usage per connection and app -### Feature: SPN - Safing Privacy Network ($) +### Feature: SPN - Safing Privacy Network ($$) - A Privacy Network aimed at use cases "between" VPN and Tor. - Uses onion encryption over multiple hops just like Tor. @@ -87,15 +82,11 @@ Portmaster is a privacy suite for your Windows and Linux desktop. - Change routing algorithm and focus per app. - Nodes are hosted by Safing (company behind Portmaster) and the community. - Speeds are pretty decent (>100MBit/s). - -#### Further Readings: - -- [Portmaster Architecture Overview](https://wiki.safing.io/en/Portmaster/Architecture/Overview) -- [SPN Whitepaper](https://safing.io/files/whitepaper/Gate17.pdf) +- Further Reading: [SPN Whitepaper](https://safing.io/files/whitepaper/Gate17.pdf) ## Documentation -All details and guides live in the dedicated [wiki](https://wiki.safing.io/) +All details and guides in the dedicated [wiki](https://wiki.safing.io/) - [Getting Started](https://wiki.safing.io/en/Portmaster/App) - Install @@ -105,4 +96,12 @@ All details and guides live in the dedicated [wiki](https://wiki.safing.io/) - [VPN Compatibility](https://wiki.safing.io/en/Portmaster/App/Compatibility#vpn-compatibly) - [Software Compatibility](https://wiki.safing.io/en/Portmaster/App/Compatibility) - [Architecture](https://wiki.safing.io/en/Portmaster/Architecture) +- [Settings Handbook](https://docs.safing.io/portmaster/settings) +- [Portmaster Developer API](https://docs.safing.io/portmaster/api) +# Build Portmaster Yourself (WIP) + +1. [Install Earthly CLI](https://earthly.dev/get-earthly) +2. [Install Docker Engine](https://docs.docker.com/engine/install/) +3. Run `earthly +release` +4. Find artifacts in `./dist` diff --git a/assets/data/fonts/Roboto-300/LICENSE.txt b/assets/data/fonts/Roboto-300/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-300/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-300/Roboto-300.eot b/assets/data/fonts/Roboto-300/Roboto-300.eot new file mode 100644 index 00000000..826acfda Binary files /dev/null and b/assets/data/fonts/Roboto-300/Roboto-300.eot differ diff --git a/assets/data/fonts/Roboto-300/Roboto-300.svg b/assets/data/fonts/Roboto-300/Roboto-300.svg new file mode 100644 index 00000000..52b28327 --- /dev/null +++ b/assets/data/fonts/Roboto-300/Roboto-300.svg @@ -0,0 +1,314 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-300/Roboto-300.ttf b/assets/data/fonts/Roboto-300/Roboto-300.ttf new file mode 100644 index 00000000..66bc5ab8 Binary files /dev/null and b/assets/data/fonts/Roboto-300/Roboto-300.ttf differ diff --git a/assets/data/fonts/Roboto-300/Roboto-300.woff b/assets/data/fonts/Roboto-300/Roboto-300.woff new file mode 100644 index 00000000..1bff3ec4 Binary files /dev/null and b/assets/data/fonts/Roboto-300/Roboto-300.woff differ diff --git a/assets/data/fonts/Roboto-300/Roboto-300.woff2 b/assets/data/fonts/Roboto-300/Roboto-300.woff2 new file mode 100644 index 00000000..4411cbc8 Binary files /dev/null and b/assets/data/fonts/Roboto-300/Roboto-300.woff2 differ diff --git a/assets/data/fonts/Roboto-300italic/LICENSE.txt b/assets/data/fonts/Roboto-300italic/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-300italic/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-300italic/Roboto-300italic.eot b/assets/data/fonts/Roboto-300italic/Roboto-300italic.eot new file mode 100644 index 00000000..c47c43ec Binary files /dev/null and b/assets/data/fonts/Roboto-300italic/Roboto-300italic.eot differ diff --git a/assets/data/fonts/Roboto-300italic/Roboto-300italic.svg b/assets/data/fonts/Roboto-300italic/Roboto-300italic.svg new file mode 100644 index 00000000..ea86b201 --- /dev/null +++ b/assets/data/fonts/Roboto-300italic/Roboto-300italic.svg @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-300italic/Roboto-300italic.ttf b/assets/data/fonts/Roboto-300italic/Roboto-300italic.ttf new file mode 100644 index 00000000..ef1d13ce Binary files /dev/null and b/assets/data/fonts/Roboto-300italic/Roboto-300italic.ttf differ diff --git a/assets/data/fonts/Roboto-300italic/Roboto-300italic.woff b/assets/data/fonts/Roboto-300italic/Roboto-300italic.woff new file mode 100644 index 00000000..fc4a8b5a Binary files /dev/null and b/assets/data/fonts/Roboto-300italic/Roboto-300italic.woff differ diff --git a/assets/data/fonts/Roboto-300italic/Roboto-300italic.woff2 b/assets/data/fonts/Roboto-300italic/Roboto-300italic.woff2 new file mode 100644 index 00000000..05fdb0ae Binary files /dev/null and b/assets/data/fonts/Roboto-300italic/Roboto-300italic.woff2 differ diff --git a/assets/data/fonts/Roboto-500/LICENSE.txt b/assets/data/fonts/Roboto-500/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-500/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-500/Roboto-500.eot b/assets/data/fonts/Roboto-500/Roboto-500.eot new file mode 100644 index 00000000..8c06caa2 Binary files /dev/null and b/assets/data/fonts/Roboto-500/Roboto-500.eot differ diff --git a/assets/data/fonts/Roboto-500/Roboto-500.svg b/assets/data/fonts/Roboto-500/Roboto-500.svg new file mode 100644 index 00000000..2b989161 --- /dev/null +++ b/assets/data/fonts/Roboto-500/Roboto-500.svg @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-500/Roboto-500.ttf b/assets/data/fonts/Roboto-500/Roboto-500.ttf new file mode 100644 index 00000000..8d6fa924 Binary files /dev/null and b/assets/data/fonts/Roboto-500/Roboto-500.ttf differ diff --git a/assets/data/fonts/Roboto-500/Roboto-500.woff b/assets/data/fonts/Roboto-500/Roboto-500.woff new file mode 100644 index 00000000..d3c82e18 Binary files /dev/null and b/assets/data/fonts/Roboto-500/Roboto-500.woff differ diff --git a/assets/data/fonts/Roboto-500/Roboto-500.woff2 b/assets/data/fonts/Roboto-500/Roboto-500.woff2 new file mode 100644 index 00000000..6be92c71 Binary files /dev/null and b/assets/data/fonts/Roboto-500/Roboto-500.woff2 differ diff --git a/assets/data/fonts/Roboto-500italic/LICENSE.txt b/assets/data/fonts/Roboto-500italic/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-500italic/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-500italic/Roboto-500italic.eot b/assets/data/fonts/Roboto-500italic/Roboto-500italic.eot new file mode 100644 index 00000000..2b253af0 Binary files /dev/null and b/assets/data/fonts/Roboto-500italic/Roboto-500italic.eot differ diff --git a/assets/data/fonts/Roboto-500italic/Roboto-500italic.svg b/assets/data/fonts/Roboto-500italic/Roboto-500italic.svg new file mode 100644 index 00000000..43c3be61 --- /dev/null +++ b/assets/data/fonts/Roboto-500italic/Roboto-500italic.svg @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-500italic/Roboto-500italic.ttf b/assets/data/fonts/Roboto-500italic/Roboto-500italic.ttf new file mode 100644 index 00000000..28d03db9 Binary files /dev/null and b/assets/data/fonts/Roboto-500italic/Roboto-500italic.ttf differ diff --git a/assets/data/fonts/Roboto-500italic/Roboto-500italic.woff b/assets/data/fonts/Roboto-500italic/Roboto-500italic.woff new file mode 100644 index 00000000..072ca9b2 Binary files /dev/null and b/assets/data/fonts/Roboto-500italic/Roboto-500italic.woff differ diff --git a/assets/data/fonts/Roboto-500italic/Roboto-500italic.woff2 b/assets/data/fonts/Roboto-500italic/Roboto-500italic.woff2 new file mode 100644 index 00000000..382866ae Binary files /dev/null and b/assets/data/fonts/Roboto-500italic/Roboto-500italic.woff2 differ diff --git a/assets/data/fonts/Roboto-700/LICENSE.txt b/assets/data/fonts/Roboto-700/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-700/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-700/Roboto-700.eot b/assets/data/fonts/Roboto-700/Roboto-700.eot new file mode 100644 index 00000000..f89cad7b Binary files /dev/null and b/assets/data/fonts/Roboto-700/Roboto-700.eot differ diff --git a/assets/data/fonts/Roboto-700/Roboto-700.svg b/assets/data/fonts/Roboto-700/Roboto-700.svg new file mode 100644 index 00000000..fc8d42f9 --- /dev/null +++ b/assets/data/fonts/Roboto-700/Roboto-700.svg @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-700/Roboto-700.ttf b/assets/data/fonts/Roboto-700/Roboto-700.ttf new file mode 100644 index 00000000..19090afb Binary files /dev/null and b/assets/data/fonts/Roboto-700/Roboto-700.ttf differ diff --git a/assets/data/fonts/Roboto-700/Roboto-700.woff b/assets/data/fonts/Roboto-700/Roboto-700.woff new file mode 100644 index 00000000..3143de29 Binary files /dev/null and b/assets/data/fonts/Roboto-700/Roboto-700.woff differ diff --git a/assets/data/fonts/Roboto-700/Roboto-700.woff2 b/assets/data/fonts/Roboto-700/Roboto-700.woff2 new file mode 100644 index 00000000..3b2dd4e2 Binary files /dev/null and b/assets/data/fonts/Roboto-700/Roboto-700.woff2 differ diff --git a/assets/data/fonts/Roboto-700italic/LICENSE.txt b/assets/data/fonts/Roboto-700italic/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-700italic/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-700italic/Roboto-700italic.eot b/assets/data/fonts/Roboto-700italic/Roboto-700italic.eot new file mode 100644 index 00000000..b8bbdf22 Binary files /dev/null and b/assets/data/fonts/Roboto-700italic/Roboto-700italic.eot differ diff --git a/assets/data/fonts/Roboto-700italic/Roboto-700italic.svg b/assets/data/fonts/Roboto-700italic/Roboto-700italic.svg new file mode 100644 index 00000000..c71c29ec --- /dev/null +++ b/assets/data/fonts/Roboto-700italic/Roboto-700italic.svg @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-700italic/Roboto-700italic.ttf b/assets/data/fonts/Roboto-700italic/Roboto-700italic.ttf new file mode 100644 index 00000000..a20e3889 Binary files /dev/null and b/assets/data/fonts/Roboto-700italic/Roboto-700italic.ttf differ diff --git a/assets/data/fonts/Roboto-700italic/Roboto-700italic.woff b/assets/data/fonts/Roboto-700italic/Roboto-700italic.woff new file mode 100644 index 00000000..7a0ae05e Binary files /dev/null and b/assets/data/fonts/Roboto-700italic/Roboto-700italic.woff differ diff --git a/assets/data/fonts/Roboto-700italic/Roboto-700italic.woff2 b/assets/data/fonts/Roboto-700italic/Roboto-700italic.woff2 new file mode 100644 index 00000000..91d2aa6a Binary files /dev/null and b/assets/data/fonts/Roboto-700italic/Roboto-700italic.woff2 differ diff --git a/assets/data/fonts/Roboto-italic/LICENSE.txt b/assets/data/fonts/Roboto-italic/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-italic/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-italic/Roboto-italic.eot b/assets/data/fonts/Roboto-italic/Roboto-italic.eot new file mode 100644 index 00000000..f2d020a8 Binary files /dev/null and b/assets/data/fonts/Roboto-italic/Roboto-italic.eot differ diff --git a/assets/data/fonts/Roboto-italic/Roboto-italic.svg b/assets/data/fonts/Roboto-italic/Roboto-italic.svg new file mode 100644 index 00000000..738b8295 --- /dev/null +++ b/assets/data/fonts/Roboto-italic/Roboto-italic.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-italic/Roboto-italic.ttf b/assets/data/fonts/Roboto-italic/Roboto-italic.ttf new file mode 100644 index 00000000..b0dd4a1e Binary files /dev/null and b/assets/data/fonts/Roboto-italic/Roboto-italic.ttf differ diff --git a/assets/data/fonts/Roboto-italic/Roboto-italic.woff b/assets/data/fonts/Roboto-italic/Roboto-italic.woff new file mode 100644 index 00000000..dcfeb008 Binary files /dev/null and b/assets/data/fonts/Roboto-italic/Roboto-italic.woff differ diff --git a/assets/data/fonts/Roboto-italic/Roboto-italic.woff2 b/assets/data/fonts/Roboto-italic/Roboto-italic.woff2 new file mode 100644 index 00000000..1bb77f9d Binary files /dev/null and b/assets/data/fonts/Roboto-italic/Roboto-italic.woff2 differ diff --git a/assets/data/fonts/Roboto-regular/LICENSE.txt b/assets/data/fonts/Roboto-regular/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/assets/data/fonts/Roboto-regular/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/data/fonts/Roboto-regular/Roboto-regular.eot b/assets/data/fonts/Roboto-regular/Roboto-regular.eot new file mode 100644 index 00000000..d26bc8f5 Binary files /dev/null and b/assets/data/fonts/Roboto-regular/Roboto-regular.eot differ diff --git a/assets/data/fonts/Roboto-regular/Roboto-regular.svg b/assets/data/fonts/Roboto-regular/Roboto-regular.svg new file mode 100644 index 00000000..ed55c105 --- /dev/null +++ b/assets/data/fonts/Roboto-regular/Roboto-regular.svg @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/data/fonts/Roboto-regular/Roboto-regular.ttf b/assets/data/fonts/Roboto-regular/Roboto-regular.ttf new file mode 100644 index 00000000..7b25f3ce Binary files /dev/null and b/assets/data/fonts/Roboto-regular/Roboto-regular.ttf differ diff --git a/assets/data/fonts/Roboto-regular/Roboto-regular.woff b/assets/data/fonts/Roboto-regular/Roboto-regular.woff new file mode 100644 index 00000000..5e353cf4 Binary files /dev/null and b/assets/data/fonts/Roboto-regular/Roboto-regular.woff differ diff --git a/assets/data/fonts/Roboto-regular/Roboto-regular.woff2 b/assets/data/fonts/Roboto-regular/Roboto-regular.woff2 new file mode 100644 index 00000000..d1035f9a Binary files /dev/null and b/assets/data/fonts/Roboto-regular/Roboto-regular.woff2 differ diff --git a/assets/data/fonts/roboto-slimfix.css b/assets/data/fonts/roboto-slimfix.css new file mode 100644 index 00000000..d2dd8a11 --- /dev/null +++ b/assets/data/fonts/roboto-slimfix.css @@ -0,0 +1,111 @@ +@font-face { + font-family: 'Roboto'; + font-weight: 400; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-300/Roboto-300.eot'); + src: url('/assets/vendor/fonts/Roboto-300/Roboto-300.eot?#iefix') format('embedded-opentype'), + local('Roboto Light'), + local('Roboto-300'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 500; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.eot'); + src: url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.eot?#iefix') format('embedded-opentype'), + local('Roboto'), + local('Roboto-regular'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 700; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-500/Roboto-500.eot'); + src: url('/assets/vendor/fonts/Roboto-500/Roboto-500.eot?#iefix') format('embedded-opentype'), + local('Roboto Medium'), + local('Roboto-500'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 900; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-700/Roboto-700.eot'); + src: url('/assets/vendor/fonts/Roboto-700/Roboto-700.eot?#iefix') format('embedded-opentype'), + local('Roboto Bold'), + local('Roboto-700'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 400; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.eot'); + src: url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Light Italic'), + local('Roboto-300italic'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 500; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.eot'); + src: url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Italic'), + local('Roboto-italic'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 700; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.eot'); + src: url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Medium Italic'), + local('Roboto-500italic'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 900; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.eot'); + src: url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Bold Italic'), + local('Roboto-700italic'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.svg#Roboto') format('svg'); +} diff --git a/assets/data/fonts/roboto.css b/assets/data/fonts/roboto.css new file mode 100644 index 00000000..cae1c904 --- /dev/null +++ b/assets/data/fonts/roboto.css @@ -0,0 +1,111 @@ +@font-face { + font-family: 'Roboto'; + font-weight: 300; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-300/Roboto-300.eot'); + src: url('/assets/vendor/fonts/Roboto-300/Roboto-300.eot?#iefix') format('embedded-opentype'), + local('Roboto Light'), + local('Roboto-300'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-300/Roboto-300.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 400; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.eot'); + src: url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.eot?#iefix') format('embedded-opentype'), + local('Roboto'), + local('Roboto-regular'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-regular/Roboto-regular.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 500; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-500/Roboto-500.eot'); + src: url('/assets/vendor/fonts/Roboto-500/Roboto-500.eot?#iefix') format('embedded-opentype'), + local('Roboto Medium'), + local('Roboto-500'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-500/Roboto-500.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 700; + font-style: normal; + src: url('/assets/vendor/fonts/Roboto-700/Roboto-700.eot'); + src: url('/assets/vendor/fonts/Roboto-700/Roboto-700.eot?#iefix') format('embedded-opentype'), + local('Roboto Bold'), + local('Roboto-700'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-700/Roboto-700.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 300; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.eot'); + src: url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Light Italic'), + local('Roboto-300italic'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-300italic/Roboto-300italic.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 400; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.eot'); + src: url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Italic'), + local('Roboto-italic'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-italic/Roboto-italic.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 500; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.eot'); + src: url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Medium Italic'), + local('Roboto-500italic'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-500italic/Roboto-500italic.svg#Roboto') format('svg'); +} + +@font-face { + font-family: 'Roboto'; + font-weight: 700; + font-style: italic; + src: url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.eot'); + src: url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.eot?#iefix') format('embedded-opentype'), + local('Roboto Bold Italic'), + local('Roboto-700italic'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.woff2') format('woff2'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.woff') format('woff'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.ttf') format('truetype'), + url('/assets/vendor/fonts/Roboto-700italic/Roboto-700italic.svg#Roboto') format('svg'); +} diff --git a/assets/data/icons/README.md b/assets/data/icons/README.md new file mode 100644 index 00000000..bf72d16b --- /dev/null +++ b/assets/data/icons/README.md @@ -0,0 +1,3 @@ +# .ICOs + +converted using https://www.icoconverter.com/ diff --git a/assets/data/icons/pm_dark_128.png b/assets/data/icons/pm_dark_128.png new file mode 100644 index 00000000..99193f24 Binary files /dev/null and b/assets/data/icons/pm_dark_128.png differ diff --git a/assets/data/icons/pm_dark_256.png b/assets/data/icons/pm_dark_256.png new file mode 100644 index 00000000..fe9d6acb Binary files /dev/null and b/assets/data/icons/pm_dark_256.png differ diff --git a/assets/data/icons/pm_dark_512.ico b/assets/data/icons/pm_dark_512.ico new file mode 100644 index 00000000..fdb10a67 Binary files /dev/null and b/assets/data/icons/pm_dark_512.ico differ diff --git a/assets/data/icons/pm_dark_512.png b/assets/data/icons/pm_dark_512.png new file mode 100644 index 00000000..05f504cb Binary files /dev/null and b/assets/data/icons/pm_dark_512.png differ diff --git a/assets/data/icons/pm_dark_blue_128.png b/assets/data/icons/pm_dark_blue_128.png new file mode 100644 index 00000000..e248b771 Binary files /dev/null and b/assets/data/icons/pm_dark_blue_128.png differ diff --git a/assets/data/icons/pm_dark_blue_256.png b/assets/data/icons/pm_dark_blue_256.png new file mode 100644 index 00000000..f1537946 Binary files /dev/null and b/assets/data/icons/pm_dark_blue_256.png differ diff --git a/assets/data/icons/pm_dark_blue_512.ico b/assets/data/icons/pm_dark_blue_512.ico new file mode 100644 index 00000000..d684c237 Binary files /dev/null and b/assets/data/icons/pm_dark_blue_512.ico differ diff --git a/assets/data/icons/pm_dark_blue_512.png b/assets/data/icons/pm_dark_blue_512.png new file mode 100644 index 00000000..92a860cc Binary files /dev/null and b/assets/data/icons/pm_dark_blue_512.png differ diff --git a/assets/data/icons/pm_dark_green_128.png b/assets/data/icons/pm_dark_green_128.png new file mode 100644 index 00000000..179064cd Binary files /dev/null and b/assets/data/icons/pm_dark_green_128.png differ diff --git a/assets/data/icons/pm_dark_green_256.png b/assets/data/icons/pm_dark_green_256.png new file mode 100644 index 00000000..3c219697 Binary files /dev/null and b/assets/data/icons/pm_dark_green_256.png differ diff --git a/assets/data/icons/pm_dark_green_512.ico b/assets/data/icons/pm_dark_green_512.ico new file mode 100644 index 00000000..6463edb3 Binary files /dev/null and b/assets/data/icons/pm_dark_green_512.ico differ diff --git a/assets/data/icons/pm_dark_green_512.png b/assets/data/icons/pm_dark_green_512.png new file mode 100644 index 00000000..08e96d93 Binary files /dev/null and b/assets/data/icons/pm_dark_green_512.png differ diff --git a/assets/data/icons/pm_dark_red_128.png b/assets/data/icons/pm_dark_red_128.png new file mode 100644 index 00000000..7f7449a8 Binary files /dev/null and b/assets/data/icons/pm_dark_red_128.png differ diff --git a/assets/data/icons/pm_dark_red_256.png b/assets/data/icons/pm_dark_red_256.png new file mode 100644 index 00000000..f77a8f4c Binary files /dev/null and b/assets/data/icons/pm_dark_red_256.png differ diff --git a/assets/data/icons/pm_dark_red_512.ico b/assets/data/icons/pm_dark_red_512.ico new file mode 100644 index 00000000..e12fbbcc Binary files /dev/null and b/assets/data/icons/pm_dark_red_512.ico differ diff --git a/assets/data/icons/pm_dark_red_512.png b/assets/data/icons/pm_dark_red_512.png new file mode 100644 index 00000000..77e05fc2 Binary files /dev/null and b/assets/data/icons/pm_dark_red_512.png differ diff --git a/assets/data/icons/pm_dark_yellow_128.png b/assets/data/icons/pm_dark_yellow_128.png new file mode 100644 index 00000000..d16ff38e Binary files /dev/null and b/assets/data/icons/pm_dark_yellow_128.png differ diff --git a/assets/data/icons/pm_dark_yellow_256.png b/assets/data/icons/pm_dark_yellow_256.png new file mode 100644 index 00000000..9c98b26f Binary files /dev/null and b/assets/data/icons/pm_dark_yellow_256.png differ diff --git a/assets/data/icons/pm_dark_yellow_512.ico b/assets/data/icons/pm_dark_yellow_512.ico new file mode 100644 index 00000000..a047a14c Binary files /dev/null and b/assets/data/icons/pm_dark_yellow_512.ico differ diff --git a/assets/data/icons/pm_dark_yellow_512.png b/assets/data/icons/pm_dark_yellow_512.png new file mode 100644 index 00000000..dc5697a2 Binary files /dev/null and b/assets/data/icons/pm_dark_yellow_512.png differ diff --git a/assets/data/icons/pm_light_128.png b/assets/data/icons/pm_light_128.png new file mode 100644 index 00000000..063948f1 Binary files /dev/null and b/assets/data/icons/pm_light_128.png differ diff --git a/assets/data/icons/pm_light_256.png b/assets/data/icons/pm_light_256.png new file mode 100644 index 00000000..681b0c0a Binary files /dev/null and b/assets/data/icons/pm_light_256.png differ diff --git a/assets/data/icons/pm_light_512.ico b/assets/data/icons/pm_light_512.ico new file mode 100644 index 00000000..b82c736b Binary files /dev/null and b/assets/data/icons/pm_light_512.ico differ diff --git a/assets/data/icons/pm_light_512.png b/assets/data/icons/pm_light_512.png new file mode 100644 index 00000000..35706673 Binary files /dev/null and b/assets/data/icons/pm_light_512.png differ diff --git a/assets/data/icons/pm_light_blue_128.png b/assets/data/icons/pm_light_blue_128.png new file mode 100644 index 00000000..c088b3cd Binary files /dev/null and b/assets/data/icons/pm_light_blue_128.png differ diff --git a/assets/data/icons/pm_light_blue_256.png b/assets/data/icons/pm_light_blue_256.png new file mode 100644 index 00000000..616c9336 Binary files /dev/null and b/assets/data/icons/pm_light_blue_256.png differ diff --git a/assets/data/icons/pm_light_blue_512.ico b/assets/data/icons/pm_light_blue_512.ico new file mode 100644 index 00000000..72ba2747 Binary files /dev/null and b/assets/data/icons/pm_light_blue_512.ico differ diff --git a/assets/data/icons/pm_light_blue_512.png b/assets/data/icons/pm_light_blue_512.png new file mode 100644 index 00000000..d7eaa1fc Binary files /dev/null and b/assets/data/icons/pm_light_blue_512.png differ diff --git a/assets/data/icons/pm_light_green_128.png b/assets/data/icons/pm_light_green_128.png new file mode 100644 index 00000000..c499b717 Binary files /dev/null and b/assets/data/icons/pm_light_green_128.png differ diff --git a/assets/data/icons/pm_light_green_256.png b/assets/data/icons/pm_light_green_256.png new file mode 100644 index 00000000..e78254bd Binary files /dev/null and b/assets/data/icons/pm_light_green_256.png differ diff --git a/assets/data/icons/pm_light_green_512.ico b/assets/data/icons/pm_light_green_512.ico new file mode 100644 index 00000000..6ccef48c Binary files /dev/null and b/assets/data/icons/pm_light_green_512.ico differ diff --git a/assets/data/icons/pm_light_green_512.png b/assets/data/icons/pm_light_green_512.png new file mode 100644 index 00000000..7377fb6d Binary files /dev/null and b/assets/data/icons/pm_light_green_512.png differ diff --git a/assets/data/icons/pm_light_red_128.png b/assets/data/icons/pm_light_red_128.png new file mode 100644 index 00000000..e2767f75 Binary files /dev/null and b/assets/data/icons/pm_light_red_128.png differ diff --git a/assets/data/icons/pm_light_red_256.png b/assets/data/icons/pm_light_red_256.png new file mode 100644 index 00000000..286e65fc Binary files /dev/null and b/assets/data/icons/pm_light_red_256.png differ diff --git a/assets/data/icons/pm_light_red_512.ico b/assets/data/icons/pm_light_red_512.ico new file mode 100644 index 00000000..8e0bc68a Binary files /dev/null and b/assets/data/icons/pm_light_red_512.ico differ diff --git a/assets/data/icons/pm_light_red_512.png b/assets/data/icons/pm_light_red_512.png new file mode 100644 index 00000000..9eb31896 Binary files /dev/null and b/assets/data/icons/pm_light_red_512.png differ diff --git a/assets/data/icons/pm_light_yellow_128.png b/assets/data/icons/pm_light_yellow_128.png new file mode 100644 index 00000000..e0f89794 Binary files /dev/null and b/assets/data/icons/pm_light_yellow_128.png differ diff --git a/assets/data/icons/pm_light_yellow_256.png b/assets/data/icons/pm_light_yellow_256.png new file mode 100644 index 00000000..eda4336f Binary files /dev/null and b/assets/data/icons/pm_light_yellow_256.png differ diff --git a/assets/data/icons/pm_light_yellow_512.ico b/assets/data/icons/pm_light_yellow_512.ico new file mode 100644 index 00000000..5a70125e Binary files /dev/null and b/assets/data/icons/pm_light_yellow_512.ico differ diff --git a/assets/data/icons/pm_light_yellow_512.png b/assets/data/icons/pm_light_yellow_512.png new file mode 100644 index 00000000..be87851a Binary files /dev/null and b/assets/data/icons/pm_light_yellow_512.png differ diff --git a/assets/data/img/Mobile.svg b/assets/data/img/Mobile.svg new file mode 100644 index 00000000..a7b773b9 --- /dev/null +++ b/assets/data/img/Mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/data/img/flags/AD.png b/assets/data/img/flags/AD.png new file mode 100644 index 00000000..d965a794 Binary files /dev/null and b/assets/data/img/flags/AD.png differ diff --git a/assets/data/img/flags/AE.png b/assets/data/img/flags/AE.png new file mode 100644 index 00000000..f429cc47 Binary files /dev/null and b/assets/data/img/flags/AE.png differ diff --git a/assets/data/img/flags/AF.png b/assets/data/img/flags/AF.png new file mode 100644 index 00000000..482779b5 Binary files /dev/null and b/assets/data/img/flags/AF.png differ diff --git a/assets/data/img/flags/AG.png b/assets/data/img/flags/AG.png new file mode 100644 index 00000000..6470e12b Binary files /dev/null and b/assets/data/img/flags/AG.png differ diff --git a/assets/data/img/flags/AI.png b/assets/data/img/flags/AI.png new file mode 100644 index 00000000..6c8ce550 Binary files /dev/null and b/assets/data/img/flags/AI.png differ diff --git a/assets/data/img/flags/AL.png b/assets/data/img/flags/AL.png new file mode 100644 index 00000000..69ba464d Binary files /dev/null and b/assets/data/img/flags/AL.png differ diff --git a/assets/data/img/flags/AM.png b/assets/data/img/flags/AM.png new file mode 100644 index 00000000..5b222d90 Binary files /dev/null and b/assets/data/img/flags/AM.png differ diff --git a/assets/data/img/flags/AN.png b/assets/data/img/flags/AN.png new file mode 100644 index 00000000..2c9e769b Binary files /dev/null and b/assets/data/img/flags/AN.png differ diff --git a/assets/data/img/flags/AO.png b/assets/data/img/flags/AO.png new file mode 100644 index 00000000..129a2d9e Binary files /dev/null and b/assets/data/img/flags/AO.png differ diff --git a/assets/data/img/flags/AQ.png b/assets/data/img/flags/AQ.png new file mode 100644 index 00000000..565eba0f Binary files /dev/null and b/assets/data/img/flags/AQ.png differ diff --git a/assets/data/img/flags/AR.png b/assets/data/img/flags/AR.png new file mode 100644 index 00000000..aa5049b3 Binary files /dev/null and b/assets/data/img/flags/AR.png differ diff --git a/assets/data/img/flags/AS.png b/assets/data/img/flags/AS.png new file mode 100644 index 00000000..f959e3ac Binary files /dev/null and b/assets/data/img/flags/AS.png differ diff --git a/assets/data/img/flags/AT.png b/assets/data/img/flags/AT.png new file mode 100644 index 00000000..aa8d102b Binary files /dev/null and b/assets/data/img/flags/AT.png differ diff --git a/assets/data/img/flags/AU.png b/assets/data/img/flags/AU.png new file mode 100644 index 00000000..f2fc59c8 Binary files /dev/null and b/assets/data/img/flags/AU.png differ diff --git a/assets/data/img/flags/AW.png b/assets/data/img/flags/AW.png new file mode 100644 index 00000000..6ef2467b Binary files /dev/null and b/assets/data/img/flags/AW.png differ diff --git a/assets/data/img/flags/AX.png b/assets/data/img/flags/AX.png new file mode 100644 index 00000000..21a5e1c0 Binary files /dev/null and b/assets/data/img/flags/AX.png differ diff --git a/assets/data/img/flags/AZ.png b/assets/data/img/flags/AZ.png new file mode 100644 index 00000000..b6ea7c71 Binary files /dev/null and b/assets/data/img/flags/AZ.png differ diff --git a/assets/data/img/flags/BA.png b/assets/data/img/flags/BA.png new file mode 100644 index 00000000..570594bb Binary files /dev/null and b/assets/data/img/flags/BA.png differ diff --git a/assets/data/img/flags/BB.png b/assets/data/img/flags/BB.png new file mode 100644 index 00000000..3e86dbbb Binary files /dev/null and b/assets/data/img/flags/BB.png differ diff --git a/assets/data/img/flags/BD.png b/assets/data/img/flags/BD.png new file mode 100644 index 00000000..fc7affbf Binary files /dev/null and b/assets/data/img/flags/BD.png differ diff --git a/assets/data/img/flags/BE.png b/assets/data/img/flags/BE.png new file mode 100644 index 00000000..182e9add Binary files /dev/null and b/assets/data/img/flags/BE.png differ diff --git a/assets/data/img/flags/BF.png b/assets/data/img/flags/BF.png new file mode 100644 index 00000000..2a861b5f Binary files /dev/null and b/assets/data/img/flags/BF.png differ diff --git a/assets/data/img/flags/BG.png b/assets/data/img/flags/BG.png new file mode 100644 index 00000000..903ed4f0 Binary files /dev/null and b/assets/data/img/flags/BG.png differ diff --git a/assets/data/img/flags/BH.png b/assets/data/img/flags/BH.png new file mode 100644 index 00000000..e2514bb9 Binary files /dev/null and b/assets/data/img/flags/BH.png differ diff --git a/assets/data/img/flags/BI.png b/assets/data/img/flags/BI.png new file mode 100644 index 00000000..82dc6c5b Binary files /dev/null and b/assets/data/img/flags/BI.png differ diff --git a/assets/data/img/flags/BJ.png b/assets/data/img/flags/BJ.png new file mode 100644 index 00000000..e9f24b0b Binary files /dev/null and b/assets/data/img/flags/BJ.png differ diff --git a/assets/data/img/flags/BL.png b/assets/data/img/flags/BL.png new file mode 100644 index 00000000..533cce91 Binary files /dev/null and b/assets/data/img/flags/BL.png differ diff --git a/assets/data/img/flags/BM.png b/assets/data/img/flags/BM.png new file mode 100644 index 00000000..5b66e1f6 Binary files /dev/null and b/assets/data/img/flags/BM.png differ diff --git a/assets/data/img/flags/BN.png b/assets/data/img/flags/BN.png new file mode 100644 index 00000000..64cfbb9f Binary files /dev/null and b/assets/data/img/flags/BN.png differ diff --git a/assets/data/img/flags/BO.png b/assets/data/img/flags/BO.png new file mode 100644 index 00000000..3f0c41f7 Binary files /dev/null and b/assets/data/img/flags/BO.png differ diff --git a/assets/data/img/flags/BR.png b/assets/data/img/flags/BR.png new file mode 100644 index 00000000..f97b96a2 Binary files /dev/null and b/assets/data/img/flags/BR.png differ diff --git a/assets/data/img/flags/BS.png b/assets/data/img/flags/BS.png new file mode 100644 index 00000000..10a987f1 Binary files /dev/null and b/assets/data/img/flags/BS.png differ diff --git a/assets/data/img/flags/BT.png b/assets/data/img/flags/BT.png new file mode 100644 index 00000000..fe52b872 Binary files /dev/null and b/assets/data/img/flags/BT.png differ diff --git a/assets/data/img/flags/BW.png b/assets/data/img/flags/BW.png new file mode 100644 index 00000000..8da822f1 Binary files /dev/null and b/assets/data/img/flags/BW.png differ diff --git a/assets/data/img/flags/BY.png b/assets/data/img/flags/BY.png new file mode 100644 index 00000000..772539f8 Binary files /dev/null and b/assets/data/img/flags/BY.png differ diff --git a/assets/data/img/flags/BZ.png b/assets/data/img/flags/BZ.png new file mode 100644 index 00000000..9ae67155 Binary files /dev/null and b/assets/data/img/flags/BZ.png differ diff --git a/assets/data/img/flags/CA.png b/assets/data/img/flags/CA.png new file mode 100644 index 00000000..3153c20f Binary files /dev/null and b/assets/data/img/flags/CA.png differ diff --git a/assets/data/img/flags/CC.png b/assets/data/img/flags/CC.png new file mode 100644 index 00000000..7e5d0df2 Binary files /dev/null and b/assets/data/img/flags/CC.png differ diff --git a/assets/data/img/flags/CD.png b/assets/data/img/flags/CD.png new file mode 100644 index 00000000..afebbaa7 Binary files /dev/null and b/assets/data/img/flags/CD.png differ diff --git a/assets/data/img/flags/CF.png b/assets/data/img/flags/CF.png new file mode 100644 index 00000000..60fadb29 Binary files /dev/null and b/assets/data/img/flags/CF.png differ diff --git a/assets/data/img/flags/CG.png b/assets/data/img/flags/CG.png new file mode 100644 index 00000000..7a7dc51d Binary files /dev/null and b/assets/data/img/flags/CG.png differ diff --git a/assets/data/img/flags/CH.png b/assets/data/img/flags/CH.png new file mode 100644 index 00000000..dcdb068e Binary files /dev/null and b/assets/data/img/flags/CH.png differ diff --git a/assets/data/img/flags/CI.png b/assets/data/img/flags/CI.png new file mode 100644 index 00000000..25a99ef2 Binary files /dev/null and b/assets/data/img/flags/CI.png differ diff --git a/assets/data/img/flags/CK.png b/assets/data/img/flags/CK.png new file mode 100644 index 00000000..c8eba169 Binary files /dev/null and b/assets/data/img/flags/CK.png differ diff --git a/assets/data/img/flags/CL.png b/assets/data/img/flags/CL.png new file mode 100644 index 00000000..1a7c983f Binary files /dev/null and b/assets/data/img/flags/CL.png differ diff --git a/assets/data/img/flags/CM.png b/assets/data/img/flags/CM.png new file mode 100644 index 00000000..2b4cea9a Binary files /dev/null and b/assets/data/img/flags/CM.png differ diff --git a/assets/data/img/flags/CN.png b/assets/data/img/flags/CN.png new file mode 100644 index 00000000..edd5f1de Binary files /dev/null and b/assets/data/img/flags/CN.png differ diff --git a/assets/data/img/flags/CO.png b/assets/data/img/flags/CO.png new file mode 100644 index 00000000..ad276d07 Binary files /dev/null and b/assets/data/img/flags/CO.png differ diff --git a/assets/data/img/flags/CR.png b/assets/data/img/flags/CR.png new file mode 100644 index 00000000..a102ffa4 Binary files /dev/null and b/assets/data/img/flags/CR.png differ diff --git a/assets/data/img/flags/CT.png b/assets/data/img/flags/CT.png new file mode 100644 index 00000000..c9fafe74 Binary files /dev/null and b/assets/data/img/flags/CT.png differ diff --git a/assets/data/img/flags/CU.png b/assets/data/img/flags/CU.png new file mode 100644 index 00000000..99f7118e Binary files /dev/null and b/assets/data/img/flags/CU.png differ diff --git a/assets/data/img/flags/CV.png b/assets/data/img/flags/CV.png new file mode 100644 index 00000000..7736ea1f Binary files /dev/null and b/assets/data/img/flags/CV.png differ diff --git a/assets/data/img/flags/CW.png b/assets/data/img/flags/CW.png new file mode 100644 index 00000000..3f65fa78 Binary files /dev/null and b/assets/data/img/flags/CW.png differ diff --git a/assets/data/img/flags/CX.png b/assets/data/img/flags/CX.png new file mode 100644 index 00000000..0f383db4 Binary files /dev/null and b/assets/data/img/flags/CX.png differ diff --git a/assets/data/img/flags/CY.png b/assets/data/img/flags/CY.png new file mode 100644 index 00000000..a1b08de3 Binary files /dev/null and b/assets/data/img/flags/CY.png differ diff --git a/assets/data/img/flags/CZ.png b/assets/data/img/flags/CZ.png new file mode 100644 index 00000000..95ffbf62 Binary files /dev/null and b/assets/data/img/flags/CZ.png differ diff --git a/assets/data/img/flags/DE.png b/assets/data/img/flags/DE.png new file mode 100644 index 00000000..f2f6175a Binary files /dev/null and b/assets/data/img/flags/DE.png differ diff --git a/assets/data/img/flags/DJ.png b/assets/data/img/flags/DJ.png new file mode 100644 index 00000000..a08f8e11 Binary files /dev/null and b/assets/data/img/flags/DJ.png differ diff --git a/assets/data/img/flags/DK.png b/assets/data/img/flags/DK.png new file mode 100644 index 00000000..349cb415 Binary files /dev/null and b/assets/data/img/flags/DK.png differ diff --git a/assets/data/img/flags/DM.png b/assets/data/img/flags/DM.png new file mode 100644 index 00000000..117e74d3 Binary files /dev/null and b/assets/data/img/flags/DM.png differ diff --git a/assets/data/img/flags/DO.png b/assets/data/img/flags/DO.png new file mode 100644 index 00000000..892e2e2a Binary files /dev/null and b/assets/data/img/flags/DO.png differ diff --git a/assets/data/img/flags/DZ.png b/assets/data/img/flags/DZ.png new file mode 100644 index 00000000..5e97662f Binary files /dev/null and b/assets/data/img/flags/DZ.png differ diff --git a/assets/data/img/flags/EC.png b/assets/data/img/flags/EC.png new file mode 100644 index 00000000..57410880 Binary files /dev/null and b/assets/data/img/flags/EC.png differ diff --git a/assets/data/img/flags/EE.png b/assets/data/img/flags/EE.png new file mode 100644 index 00000000..1f118992 Binary files /dev/null and b/assets/data/img/flags/EE.png differ diff --git a/assets/data/img/flags/EG.png b/assets/data/img/flags/EG.png new file mode 100644 index 00000000..0e873beb Binary files /dev/null and b/assets/data/img/flags/EG.png differ diff --git a/assets/data/img/flags/EH.png b/assets/data/img/flags/EH.png new file mode 100644 index 00000000..a5b3b1cc Binary files /dev/null and b/assets/data/img/flags/EH.png differ diff --git a/assets/data/img/flags/ER.png b/assets/data/img/flags/ER.png new file mode 100644 index 00000000..50781ce5 Binary files /dev/null and b/assets/data/img/flags/ER.png differ diff --git a/assets/data/img/flags/ES.png b/assets/data/img/flags/ES.png new file mode 100644 index 00000000..b89db685 Binary files /dev/null and b/assets/data/img/flags/ES.png differ diff --git a/assets/data/img/flags/ET.png b/assets/data/img/flags/ET.png new file mode 100644 index 00000000..aa147235 Binary files /dev/null and b/assets/data/img/flags/ET.png differ diff --git a/assets/data/img/flags/EU.png b/assets/data/img/flags/EU.png new file mode 100644 index 00000000..2bfaf108 Binary files /dev/null and b/assets/data/img/flags/EU.png differ diff --git a/assets/data/img/flags/FI.png b/assets/data/img/flags/FI.png new file mode 100644 index 00000000..b5a380c5 Binary files /dev/null and b/assets/data/img/flags/FI.png differ diff --git a/assets/data/img/flags/FJ.png b/assets/data/img/flags/FJ.png new file mode 100644 index 00000000..1cb520c5 Binary files /dev/null and b/assets/data/img/flags/FJ.png differ diff --git a/assets/data/img/flags/FK.png b/assets/data/img/flags/FK.png new file mode 100644 index 00000000..a7cadb77 Binary files /dev/null and b/assets/data/img/flags/FK.png differ diff --git a/assets/data/img/flags/FM.png b/assets/data/img/flags/FM.png new file mode 100644 index 00000000..5a9b85cc Binary files /dev/null and b/assets/data/img/flags/FM.png differ diff --git a/assets/data/img/flags/FO.png b/assets/data/img/flags/FO.png new file mode 100644 index 00000000..4a49e30c Binary files /dev/null and b/assets/data/img/flags/FO.png differ diff --git a/assets/data/img/flags/FR.png b/assets/data/img/flags/FR.png new file mode 100644 index 00000000..0706dcc0 Binary files /dev/null and b/assets/data/img/flags/FR.png differ diff --git a/assets/data/img/flags/GA.png b/assets/data/img/flags/GA.png new file mode 100644 index 00000000..38899c4a Binary files /dev/null and b/assets/data/img/flags/GA.png differ diff --git a/assets/data/img/flags/GB.png b/assets/data/img/flags/GB.png new file mode 100644 index 00000000..43ebed3b Binary files /dev/null and b/assets/data/img/flags/GB.png differ diff --git a/assets/data/img/flags/GD.png b/assets/data/img/flags/GD.png new file mode 100644 index 00000000..2d33bbbd Binary files /dev/null and b/assets/data/img/flags/GD.png differ diff --git a/assets/data/img/flags/GE.png b/assets/data/img/flags/GE.png new file mode 100644 index 00000000..7aff2749 Binary files /dev/null and b/assets/data/img/flags/GE.png differ diff --git a/assets/data/img/flags/GG.png b/assets/data/img/flags/GG.png new file mode 100644 index 00000000..c0c3a78f Binary files /dev/null and b/assets/data/img/flags/GG.png differ diff --git a/assets/data/img/flags/GH.png b/assets/data/img/flags/GH.png new file mode 100644 index 00000000..e9b79a6d Binary files /dev/null and b/assets/data/img/flags/GH.png differ diff --git a/assets/data/img/flags/GI.png b/assets/data/img/flags/GI.png new file mode 100644 index 00000000..e14ebe59 Binary files /dev/null and b/assets/data/img/flags/GI.png differ diff --git a/assets/data/img/flags/GL.png b/assets/data/img/flags/GL.png new file mode 100644 index 00000000..6b995ff1 Binary files /dev/null and b/assets/data/img/flags/GL.png differ diff --git a/assets/data/img/flags/GM.png b/assets/data/img/flags/GM.png new file mode 100644 index 00000000..72c170aa Binary files /dev/null and b/assets/data/img/flags/GM.png differ diff --git a/assets/data/img/flags/GN.png b/assets/data/img/flags/GN.png new file mode 100644 index 00000000..99830391 Binary files /dev/null and b/assets/data/img/flags/GN.png differ diff --git a/assets/data/img/flags/GQ.png b/assets/data/img/flags/GQ.png new file mode 100644 index 00000000..9b020456 Binary files /dev/null and b/assets/data/img/flags/GQ.png differ diff --git a/assets/data/img/flags/GR.png b/assets/data/img/flags/GR.png new file mode 100644 index 00000000..dc34d191 Binary files /dev/null and b/assets/data/img/flags/GR.png differ diff --git a/assets/data/img/flags/GS.png b/assets/data/img/flags/GS.png new file mode 100644 index 00000000..55392f92 Binary files /dev/null and b/assets/data/img/flags/GS.png differ diff --git a/assets/data/img/flags/GT.png b/assets/data/img/flags/GT.png new file mode 100644 index 00000000..0b4b8b4f Binary files /dev/null and b/assets/data/img/flags/GT.png differ diff --git a/assets/data/img/flags/GU.png b/assets/data/img/flags/GU.png new file mode 100644 index 00000000..31e9cc57 Binary files /dev/null and b/assets/data/img/flags/GU.png differ diff --git a/assets/data/img/flags/GW.png b/assets/data/img/flags/GW.png new file mode 100644 index 00000000..98c66331 Binary files /dev/null and b/assets/data/img/flags/GW.png differ diff --git a/assets/data/img/flags/GY.png b/assets/data/img/flags/GY.png new file mode 100644 index 00000000..8cc6d9cf Binary files /dev/null and b/assets/data/img/flags/GY.png differ diff --git a/assets/data/img/flags/HK.png b/assets/data/img/flags/HK.png new file mode 100644 index 00000000..89c38aa1 Binary files /dev/null and b/assets/data/img/flags/HK.png differ diff --git a/assets/data/img/flags/HN.png b/assets/data/img/flags/HN.png new file mode 100644 index 00000000..e794c437 Binary files /dev/null and b/assets/data/img/flags/HN.png differ diff --git a/assets/data/img/flags/HR.png b/assets/data/img/flags/HR.png new file mode 100644 index 00000000..6f845d5d Binary files /dev/null and b/assets/data/img/flags/HR.png differ diff --git a/assets/data/img/flags/HT.png b/assets/data/img/flags/HT.png new file mode 100644 index 00000000..da4dc3b1 Binary files /dev/null and b/assets/data/img/flags/HT.png differ diff --git a/assets/data/img/flags/HU.png b/assets/data/img/flags/HU.png new file mode 100644 index 00000000..98de28af Binary files /dev/null and b/assets/data/img/flags/HU.png differ diff --git a/assets/data/img/flags/IC.png b/assets/data/img/flags/IC.png new file mode 100644 index 00000000..500d9dbe Binary files /dev/null and b/assets/data/img/flags/IC.png differ diff --git a/assets/data/img/flags/ID.png b/assets/data/img/flags/ID.png new file mode 100644 index 00000000..a14683d7 Binary files /dev/null and b/assets/data/img/flags/ID.png differ diff --git a/assets/data/img/flags/IE.png b/assets/data/img/flags/IE.png new file mode 100644 index 00000000..105c26b8 Binary files /dev/null and b/assets/data/img/flags/IE.png differ diff --git a/assets/data/img/flags/IL.png b/assets/data/img/flags/IL.png new file mode 100644 index 00000000..9ad54c5d Binary files /dev/null and b/assets/data/img/flags/IL.png differ diff --git a/assets/data/img/flags/IM.png b/assets/data/img/flags/IM.png new file mode 100644 index 00000000..f0ff4665 Binary files /dev/null and b/assets/data/img/flags/IM.png differ diff --git a/assets/data/img/flags/IN.png b/assets/data/img/flags/IN.png new file mode 100644 index 00000000..f1c32fac Binary files /dev/null and b/assets/data/img/flags/IN.png differ diff --git a/assets/data/img/flags/IQ.png b/assets/data/img/flags/IQ.png new file mode 100644 index 00000000..8d5a3236 Binary files /dev/null and b/assets/data/img/flags/IQ.png differ diff --git a/assets/data/img/flags/IR.png b/assets/data/img/flags/IR.png new file mode 100644 index 00000000..354a3ac5 Binary files /dev/null and b/assets/data/img/flags/IR.png differ diff --git a/assets/data/img/flags/IS.png b/assets/data/img/flags/IS.png new file mode 100644 index 00000000..87253cdb Binary files /dev/null and b/assets/data/img/flags/IS.png differ diff --git a/assets/data/img/flags/IT.png b/assets/data/img/flags/IT.png new file mode 100644 index 00000000..ce11f1f8 Binary files /dev/null and b/assets/data/img/flags/IT.png differ diff --git a/assets/data/img/flags/JE.png b/assets/data/img/flags/JE.png new file mode 100644 index 00000000..904b6101 Binary files /dev/null and b/assets/data/img/flags/JE.png differ diff --git a/assets/data/img/flags/JM.png b/assets/data/img/flags/JM.png new file mode 100644 index 00000000..378f70d0 Binary files /dev/null and b/assets/data/img/flags/JM.png differ diff --git a/assets/data/img/flags/JO.png b/assets/data/img/flags/JO.png new file mode 100644 index 00000000..270e5248 Binary files /dev/null and b/assets/data/img/flags/JO.png differ diff --git a/assets/data/img/flags/JP.png b/assets/data/img/flags/JP.png new file mode 100644 index 00000000..78c159ac Binary files /dev/null and b/assets/data/img/flags/JP.png differ diff --git a/assets/data/img/flags/KE.png b/assets/data/img/flags/KE.png new file mode 100644 index 00000000..ecbeb5db Binary files /dev/null and b/assets/data/img/flags/KE.png differ diff --git a/assets/data/img/flags/KG.png b/assets/data/img/flags/KG.png new file mode 100644 index 00000000..12b0dadd Binary files /dev/null and b/assets/data/img/flags/KG.png differ diff --git a/assets/data/img/flags/KH.png b/assets/data/img/flags/KH.png new file mode 100644 index 00000000..6fb7f578 Binary files /dev/null and b/assets/data/img/flags/KH.png differ diff --git a/assets/data/img/flags/KI.png b/assets/data/img/flags/KI.png new file mode 100644 index 00000000..e2762a67 Binary files /dev/null and b/assets/data/img/flags/KI.png differ diff --git a/assets/data/img/flags/KM.png b/assets/data/img/flags/KM.png new file mode 100644 index 00000000..43d8a75a Binary files /dev/null and b/assets/data/img/flags/KM.png differ diff --git a/assets/data/img/flags/KN.png b/assets/data/img/flags/KN.png new file mode 100644 index 00000000..5decf8da Binary files /dev/null and b/assets/data/img/flags/KN.png differ diff --git a/assets/data/img/flags/KP.png b/assets/data/img/flags/KP.png new file mode 100644 index 00000000..b303f8e7 Binary files /dev/null and b/assets/data/img/flags/KP.png differ diff --git a/assets/data/img/flags/KR.png b/assets/data/img/flags/KR.png new file mode 100644 index 00000000..d21bef98 Binary files /dev/null and b/assets/data/img/flags/KR.png differ diff --git a/assets/data/img/flags/KW.png b/assets/data/img/flags/KW.png new file mode 100644 index 00000000..6f7010b8 Binary files /dev/null and b/assets/data/img/flags/KW.png differ diff --git a/assets/data/img/flags/KY.png b/assets/data/img/flags/KY.png new file mode 100644 index 00000000..c4bfbd99 Binary files /dev/null and b/assets/data/img/flags/KY.png differ diff --git a/assets/data/img/flags/KZ.png b/assets/data/img/flags/KZ.png new file mode 100644 index 00000000..1a0ca4fd Binary files /dev/null and b/assets/data/img/flags/KZ.png differ diff --git a/assets/data/img/flags/LA.png b/assets/data/img/flags/LA.png new file mode 100644 index 00000000..f78e67f6 Binary files /dev/null and b/assets/data/img/flags/LA.png differ diff --git a/assets/data/img/flags/LB.png b/assets/data/img/flags/LB.png new file mode 100644 index 00000000..a9643c34 Binary files /dev/null and b/assets/data/img/flags/LB.png differ diff --git a/assets/data/img/flags/LC.png b/assets/data/img/flags/LC.png new file mode 100644 index 00000000..ab5916ba Binary files /dev/null and b/assets/data/img/flags/LC.png differ diff --git a/assets/data/img/flags/LI.png b/assets/data/img/flags/LI.png new file mode 100644 index 00000000..cf7bbe49 Binary files /dev/null and b/assets/data/img/flags/LI.png differ diff --git a/assets/data/img/flags/LICENSE.txt b/assets/data/img/flags/LICENSE.txt new file mode 100644 index 00000000..0836cb40 --- /dev/null +++ b/assets/data/img/flags/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright (c) 2017 Go Squared Ltd. http://www.gosquared.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/assets/data/img/flags/LK.png b/assets/data/img/flags/LK.png new file mode 100644 index 00000000..a60c8edc Binary files /dev/null and b/assets/data/img/flags/LK.png differ diff --git a/assets/data/img/flags/LR.png b/assets/data/img/flags/LR.png new file mode 100644 index 00000000..dd3a57f7 Binary files /dev/null and b/assets/data/img/flags/LR.png differ diff --git a/assets/data/img/flags/LS.png b/assets/data/img/flags/LS.png new file mode 100644 index 00000000..ad2aa4a2 Binary files /dev/null and b/assets/data/img/flags/LS.png differ diff --git a/assets/data/img/flags/LT.png b/assets/data/img/flags/LT.png new file mode 100644 index 00000000..f40f2e28 Binary files /dev/null and b/assets/data/img/flags/LT.png differ diff --git a/assets/data/img/flags/LU.png b/assets/data/img/flags/LU.png new file mode 100644 index 00000000..92e72f9d Binary files /dev/null and b/assets/data/img/flags/LU.png differ diff --git a/assets/data/img/flags/LV.png b/assets/data/img/flags/LV.png new file mode 100644 index 00000000..3966acfc Binary files /dev/null and b/assets/data/img/flags/LV.png differ diff --git a/assets/data/img/flags/LY.png b/assets/data/img/flags/LY.png new file mode 100644 index 00000000..4db08453 Binary files /dev/null and b/assets/data/img/flags/LY.png differ diff --git a/assets/data/img/flags/MA.png b/assets/data/img/flags/MA.png new file mode 100644 index 00000000..69424d59 Binary files /dev/null and b/assets/data/img/flags/MA.png differ diff --git a/assets/data/img/flags/MC.png b/assets/data/img/flags/MC.png new file mode 100644 index 00000000..a14683d7 Binary files /dev/null and b/assets/data/img/flags/MC.png differ diff --git a/assets/data/img/flags/MD.png b/assets/data/img/flags/MD.png new file mode 100644 index 00000000..21fd6eca Binary files /dev/null and b/assets/data/img/flags/MD.png differ diff --git a/assets/data/img/flags/ME.png b/assets/data/img/flags/ME.png new file mode 100644 index 00000000..0ca932d9 Binary files /dev/null and b/assets/data/img/flags/ME.png differ diff --git a/assets/data/img/flags/MF.png b/assets/data/img/flags/MF.png new file mode 100644 index 00000000..16692f71 Binary files /dev/null and b/assets/data/img/flags/MF.png differ diff --git a/assets/data/img/flags/MG.png b/assets/data/img/flags/MG.png new file mode 100644 index 00000000..09f2469a Binary files /dev/null and b/assets/data/img/flags/MG.png differ diff --git a/assets/data/img/flags/MH.png b/assets/data/img/flags/MH.png new file mode 100644 index 00000000..3ffcf013 Binary files /dev/null and b/assets/data/img/flags/MH.png differ diff --git a/assets/data/img/flags/MK.png b/assets/data/img/flags/MK.png new file mode 100644 index 00000000..a6765095 Binary files /dev/null and b/assets/data/img/flags/MK.png differ diff --git a/assets/data/img/flags/ML.png b/assets/data/img/flags/ML.png new file mode 100644 index 00000000..bd238418 Binary files /dev/null and b/assets/data/img/flags/ML.png differ diff --git a/assets/data/img/flags/MM.png b/assets/data/img/flags/MM.png new file mode 100644 index 00000000..1bf0d5bb Binary files /dev/null and b/assets/data/img/flags/MM.png differ diff --git a/assets/data/img/flags/MN.png b/assets/data/img/flags/MN.png new file mode 100644 index 00000000..67a53355 Binary files /dev/null and b/assets/data/img/flags/MN.png differ diff --git a/assets/data/img/flags/MO.png b/assets/data/img/flags/MO.png new file mode 100644 index 00000000..2dc29c86 Binary files /dev/null and b/assets/data/img/flags/MO.png differ diff --git a/assets/data/img/flags/MP.png b/assets/data/img/flags/MP.png new file mode 100644 index 00000000..b5057540 Binary files /dev/null and b/assets/data/img/flags/MP.png differ diff --git a/assets/data/img/flags/MQ.png b/assets/data/img/flags/MQ.png new file mode 100644 index 00000000..4e9f76b6 Binary files /dev/null and b/assets/data/img/flags/MQ.png differ diff --git a/assets/data/img/flags/MR.png b/assets/data/img/flags/MR.png new file mode 100644 index 00000000..6bda8613 Binary files /dev/null and b/assets/data/img/flags/MR.png differ diff --git a/assets/data/img/flags/MS.png b/assets/data/img/flags/MS.png new file mode 100644 index 00000000..a860c6fe Binary files /dev/null and b/assets/data/img/flags/MS.png differ diff --git a/assets/data/img/flags/MT.png b/assets/data/img/flags/MT.png new file mode 100644 index 00000000..93d502bd Binary files /dev/null and b/assets/data/img/flags/MT.png differ diff --git a/assets/data/img/flags/MU.png b/assets/data/img/flags/MU.png new file mode 100644 index 00000000..6bf52359 Binary files /dev/null and b/assets/data/img/flags/MU.png differ diff --git a/assets/data/img/flags/MV.png b/assets/data/img/flags/MV.png new file mode 100644 index 00000000..b87bb2ec Binary files /dev/null and b/assets/data/img/flags/MV.png differ diff --git a/assets/data/img/flags/MW.png b/assets/data/img/flags/MW.png new file mode 100644 index 00000000..d75a8d30 Binary files /dev/null and b/assets/data/img/flags/MW.png differ diff --git a/assets/data/img/flags/MX.png b/assets/data/img/flags/MX.png new file mode 100644 index 00000000..8fa79193 Binary files /dev/null and b/assets/data/img/flags/MX.png differ diff --git a/assets/data/img/flags/MY.png b/assets/data/img/flags/MY.png new file mode 100644 index 00000000..a8e39961 Binary files /dev/null and b/assets/data/img/flags/MY.png differ diff --git a/assets/data/img/flags/MZ.png b/assets/data/img/flags/MZ.png new file mode 100644 index 00000000..0fdc38c7 Binary files /dev/null and b/assets/data/img/flags/MZ.png differ diff --git a/assets/data/img/flags/NA.png b/assets/data/img/flags/NA.png new file mode 100644 index 00000000..52e2a792 Binary files /dev/null and b/assets/data/img/flags/NA.png differ diff --git a/assets/data/img/flags/NC.png b/assets/data/img/flags/NC.png new file mode 100644 index 00000000..e3288acf Binary files /dev/null and b/assets/data/img/flags/NC.png differ diff --git a/assets/data/img/flags/NE.png b/assets/data/img/flags/NE.png new file mode 100644 index 00000000..841e77fb Binary files /dev/null and b/assets/data/img/flags/NE.png differ diff --git a/assets/data/img/flags/NF.png b/assets/data/img/flags/NF.png new file mode 100644 index 00000000..7c1af026 Binary files /dev/null and b/assets/data/img/flags/NF.png differ diff --git a/assets/data/img/flags/NG.png b/assets/data/img/flags/NG.png new file mode 100644 index 00000000..25fe78f0 Binary files /dev/null and b/assets/data/img/flags/NG.png differ diff --git a/assets/data/img/flags/NI.png b/assets/data/img/flags/NI.png new file mode 100644 index 00000000..0f66accb Binary files /dev/null and b/assets/data/img/flags/NI.png differ diff --git a/assets/data/img/flags/NL.png b/assets/data/img/flags/NL.png new file mode 100644 index 00000000..036658e7 Binary files /dev/null and b/assets/data/img/flags/NL.png differ diff --git a/assets/data/img/flags/NO.png b/assets/data/img/flags/NO.png new file mode 100644 index 00000000..38a13c4c Binary files /dev/null and b/assets/data/img/flags/NO.png differ diff --git a/assets/data/img/flags/NP.png b/assets/data/img/flags/NP.png new file mode 100644 index 00000000..eed654be Binary files /dev/null and b/assets/data/img/flags/NP.png differ diff --git a/assets/data/img/flags/NR.png b/assets/data/img/flags/NR.png new file mode 100644 index 00000000..4b2d0806 Binary files /dev/null and b/assets/data/img/flags/NR.png differ diff --git a/assets/data/img/flags/NU.png b/assets/data/img/flags/NU.png new file mode 100644 index 00000000..d791c4af Binary files /dev/null and b/assets/data/img/flags/NU.png differ diff --git a/assets/data/img/flags/NZ.png b/assets/data/img/flags/NZ.png new file mode 100644 index 00000000..913b18af Binary files /dev/null and b/assets/data/img/flags/NZ.png differ diff --git a/assets/data/img/flags/OM.png b/assets/data/img/flags/OM.png new file mode 100644 index 00000000..b2a16c03 Binary files /dev/null and b/assets/data/img/flags/OM.png differ diff --git a/assets/data/img/flags/PA.png b/assets/data/img/flags/PA.png new file mode 100644 index 00000000..fc0a34a3 Binary files /dev/null and b/assets/data/img/flags/PA.png differ diff --git a/assets/data/img/flags/PE.png b/assets/data/img/flags/PE.png new file mode 100644 index 00000000..ce31457e Binary files /dev/null and b/assets/data/img/flags/PE.png differ diff --git a/assets/data/img/flags/PF.png b/assets/data/img/flags/PF.png new file mode 100644 index 00000000..c9327096 Binary files /dev/null and b/assets/data/img/flags/PF.png differ diff --git a/assets/data/img/flags/PG.png b/assets/data/img/flags/PG.png new file mode 100644 index 00000000..68b758df Binary files /dev/null and b/assets/data/img/flags/PG.png differ diff --git a/assets/data/img/flags/PH.png b/assets/data/img/flags/PH.png new file mode 100644 index 00000000..dc75142c Binary files /dev/null and b/assets/data/img/flags/PH.png differ diff --git a/assets/data/img/flags/PK.png b/assets/data/img/flags/PK.png new file mode 100644 index 00000000..014af065 Binary files /dev/null and b/assets/data/img/flags/PK.png differ diff --git a/assets/data/img/flags/PL.png b/assets/data/img/flags/PL.png new file mode 100644 index 00000000..4d0fc51f Binary files /dev/null and b/assets/data/img/flags/PL.png differ diff --git a/assets/data/img/flags/PN.png b/assets/data/img/flags/PN.png new file mode 100644 index 00000000..c046e9bc Binary files /dev/null and b/assets/data/img/flags/PN.png differ diff --git a/assets/data/img/flags/PR.png b/assets/data/img/flags/PR.png new file mode 100644 index 00000000..7d54f19a Binary files /dev/null and b/assets/data/img/flags/PR.png differ diff --git a/assets/data/img/flags/PS.png b/assets/data/img/flags/PS.png new file mode 100644 index 00000000..d4d85dcf Binary files /dev/null and b/assets/data/img/flags/PS.png differ diff --git a/assets/data/img/flags/PT.png b/assets/data/img/flags/PT.png new file mode 100644 index 00000000..18e276e5 Binary files /dev/null and b/assets/data/img/flags/PT.png differ diff --git a/assets/data/img/flags/PW.png b/assets/data/img/flags/PW.png new file mode 100644 index 00000000..f9bcdc6e Binary files /dev/null and b/assets/data/img/flags/PW.png differ diff --git a/assets/data/img/flags/PY.png b/assets/data/img/flags/PY.png new file mode 100644 index 00000000..c289b6cf Binary files /dev/null and b/assets/data/img/flags/PY.png differ diff --git a/assets/data/img/flags/QA.png b/assets/data/img/flags/QA.png new file mode 100644 index 00000000..95c7485d Binary files /dev/null and b/assets/data/img/flags/QA.png differ diff --git a/assets/data/img/flags/RE.png b/assets/data/img/flags/RE.png new file mode 100644 index 00000000..2ff851c8 Binary files /dev/null and b/assets/data/img/flags/RE.png differ diff --git a/assets/data/img/flags/RO.png b/assets/data/img/flags/RO.png new file mode 100644 index 00000000..3d9c2a3e Binary files /dev/null and b/assets/data/img/flags/RO.png differ diff --git a/assets/data/img/flags/RS.png b/assets/data/img/flags/RS.png new file mode 100644 index 00000000..d95bcdfc Binary files /dev/null and b/assets/data/img/flags/RS.png differ diff --git a/assets/data/img/flags/RU.png b/assets/data/img/flags/RU.png new file mode 100644 index 00000000..a4318e7d Binary files /dev/null and b/assets/data/img/flags/RU.png differ diff --git a/assets/data/img/flags/RW.png b/assets/data/img/flags/RW.png new file mode 100644 index 00000000..00f5e1e0 Binary files /dev/null and b/assets/data/img/flags/RW.png differ diff --git a/assets/data/img/flags/SA.png b/assets/data/img/flags/SA.png new file mode 100644 index 00000000..ba3f2de9 Binary files /dev/null and b/assets/data/img/flags/SA.png differ diff --git a/assets/data/img/flags/SB.png b/assets/data/img/flags/SB.png new file mode 100644 index 00000000..1b6384a0 Binary files /dev/null and b/assets/data/img/flags/SB.png differ diff --git a/assets/data/img/flags/SC.png b/assets/data/img/flags/SC.png new file mode 100644 index 00000000..2a495183 Binary files /dev/null and b/assets/data/img/flags/SC.png differ diff --git a/assets/data/img/flags/SD.png b/assets/data/img/flags/SD.png new file mode 100644 index 00000000..5fc853b1 Binary files /dev/null and b/assets/data/img/flags/SD.png differ diff --git a/assets/data/img/flags/SE.png b/assets/data/img/flags/SE.png new file mode 100644 index 00000000..ad7854b7 Binary files /dev/null and b/assets/data/img/flags/SE.png differ diff --git a/assets/data/img/flags/SG.png b/assets/data/img/flags/SG.png new file mode 100644 index 00000000..8b1c5f03 Binary files /dev/null and b/assets/data/img/flags/SG.png differ diff --git a/assets/data/img/flags/SH.png b/assets/data/img/flags/SH.png new file mode 100644 index 00000000..4b2961be Binary files /dev/null and b/assets/data/img/flags/SH.png differ diff --git a/assets/data/img/flags/SI.png b/assets/data/img/flags/SI.png new file mode 100644 index 00000000..08cc3f4e Binary files /dev/null and b/assets/data/img/flags/SI.png differ diff --git a/assets/data/img/flags/SK.png b/assets/data/img/flags/SK.png new file mode 100644 index 00000000..d622ef05 Binary files /dev/null and b/assets/data/img/flags/SK.png differ diff --git a/assets/data/img/flags/SL.png b/assets/data/img/flags/SL.png new file mode 100644 index 00000000..e8a3530f Binary files /dev/null and b/assets/data/img/flags/SL.png differ diff --git a/assets/data/img/flags/SM.png b/assets/data/img/flags/SM.png new file mode 100644 index 00000000..f0d65724 Binary files /dev/null and b/assets/data/img/flags/SM.png differ diff --git a/assets/data/img/flags/SN.png b/assets/data/img/flags/SN.png new file mode 100644 index 00000000..a4fc08fd Binary files /dev/null and b/assets/data/img/flags/SN.png differ diff --git a/assets/data/img/flags/SO.png b/assets/data/img/flags/SO.png new file mode 100644 index 00000000..3f0f4163 Binary files /dev/null and b/assets/data/img/flags/SO.png differ diff --git a/assets/data/img/flags/SR.png b/assets/data/img/flags/SR.png new file mode 100644 index 00000000..6a8eea24 Binary files /dev/null and b/assets/data/img/flags/SR.png differ diff --git a/assets/data/img/flags/SS.png b/assets/data/img/flags/SS.png new file mode 100644 index 00000000..c71cafaa Binary files /dev/null and b/assets/data/img/flags/SS.png differ diff --git a/assets/data/img/flags/ST.png b/assets/data/img/flags/ST.png new file mode 100644 index 00000000..480886ca Binary files /dev/null and b/assets/data/img/flags/ST.png differ diff --git a/assets/data/img/flags/SV.png b/assets/data/img/flags/SV.png new file mode 100644 index 00000000..b5f69fae Binary files /dev/null and b/assets/data/img/flags/SV.png differ diff --git a/assets/data/img/flags/SX.png b/assets/data/img/flags/SX.png new file mode 100644 index 00000000..25f4f559 Binary files /dev/null and b/assets/data/img/flags/SX.png differ diff --git a/assets/data/img/flags/SY.png b/assets/data/img/flags/SY.png new file mode 100644 index 00000000..dd5927a6 Binary files /dev/null and b/assets/data/img/flags/SY.png differ diff --git a/assets/data/img/flags/SZ.png b/assets/data/img/flags/SZ.png new file mode 100644 index 00000000..b0615c36 Binary files /dev/null and b/assets/data/img/flags/SZ.png differ diff --git a/assets/data/img/flags/TC.png b/assets/data/img/flags/TC.png new file mode 100644 index 00000000..b17607b9 Binary files /dev/null and b/assets/data/img/flags/TC.png differ diff --git a/assets/data/img/flags/TD.png b/assets/data/img/flags/TD.png new file mode 100644 index 00000000..787eebb6 Binary files /dev/null and b/assets/data/img/flags/TD.png differ diff --git a/assets/data/img/flags/TF.png b/assets/data/img/flags/TF.png new file mode 100644 index 00000000..82929045 Binary files /dev/null and b/assets/data/img/flags/TF.png differ diff --git a/assets/data/img/flags/TG.png b/assets/data/img/flags/TG.png new file mode 100644 index 00000000..be814c69 Binary files /dev/null and b/assets/data/img/flags/TG.png differ diff --git a/assets/data/img/flags/TH.png b/assets/data/img/flags/TH.png new file mode 100644 index 00000000..5ff77db9 Binary files /dev/null and b/assets/data/img/flags/TH.png differ diff --git a/assets/data/img/flags/TJ.png b/assets/data/img/flags/TJ.png new file mode 100644 index 00000000..b0b546be Binary files /dev/null and b/assets/data/img/flags/TJ.png differ diff --git a/assets/data/img/flags/TK.png b/assets/data/img/flags/TK.png new file mode 100644 index 00000000..b70e8235 Binary files /dev/null and b/assets/data/img/flags/TK.png differ diff --git a/assets/data/img/flags/TL.png b/assets/data/img/flags/TL.png new file mode 100644 index 00000000..b7e77dce Binary files /dev/null and b/assets/data/img/flags/TL.png differ diff --git a/assets/data/img/flags/TM.png b/assets/data/img/flags/TM.png new file mode 100644 index 00000000..e6f69d73 Binary files /dev/null and b/assets/data/img/flags/TM.png differ diff --git a/assets/data/img/flags/TN.png b/assets/data/img/flags/TN.png new file mode 100644 index 00000000..2548fd92 Binary files /dev/null and b/assets/data/img/flags/TN.png differ diff --git a/assets/data/img/flags/TO.png b/assets/data/img/flags/TO.png new file mode 100644 index 00000000..f96d9964 Binary files /dev/null and b/assets/data/img/flags/TO.png differ diff --git a/assets/data/img/flags/TR.png b/assets/data/img/flags/TR.png new file mode 100644 index 00000000..3af317d9 Binary files /dev/null and b/assets/data/img/flags/TR.png differ diff --git a/assets/data/img/flags/TT.png b/assets/data/img/flags/TT.png new file mode 100644 index 00000000..890321ab Binary files /dev/null and b/assets/data/img/flags/TT.png differ diff --git a/assets/data/img/flags/TV.png b/assets/data/img/flags/TV.png new file mode 100644 index 00000000..2ec31605 Binary files /dev/null and b/assets/data/img/flags/TV.png differ diff --git a/assets/data/img/flags/TW.png b/assets/data/img/flags/TW.png new file mode 100644 index 00000000..26425e4b Binary files /dev/null and b/assets/data/img/flags/TW.png differ diff --git a/assets/data/img/flags/TZ.png b/assets/data/img/flags/TZ.png new file mode 100644 index 00000000..c1671cf7 Binary files /dev/null and b/assets/data/img/flags/TZ.png differ diff --git a/assets/data/img/flags/UA.png b/assets/data/img/flags/UA.png new file mode 100644 index 00000000..74c20122 Binary files /dev/null and b/assets/data/img/flags/UA.png differ diff --git a/assets/data/img/flags/UG.png b/assets/data/img/flags/UG.png new file mode 100644 index 00000000..c8c24436 Binary files /dev/null and b/assets/data/img/flags/UG.png differ diff --git a/assets/data/img/flags/US.png b/assets/data/img/flags/US.png new file mode 100644 index 00000000..31aa3f18 Binary files /dev/null and b/assets/data/img/flags/US.png differ diff --git a/assets/data/img/flags/UY.png b/assets/data/img/flags/UY.png new file mode 100644 index 00000000..9397cece Binary files /dev/null and b/assets/data/img/flags/UY.png differ diff --git a/assets/data/img/flags/UZ.png b/assets/data/img/flags/UZ.png new file mode 100644 index 00000000..1df6c882 Binary files /dev/null and b/assets/data/img/flags/UZ.png differ diff --git a/assets/data/img/flags/VA.png b/assets/data/img/flags/VA.png new file mode 100644 index 00000000..25a852e9 Binary files /dev/null and b/assets/data/img/flags/VA.png differ diff --git a/assets/data/img/flags/VC.png b/assets/data/img/flags/VC.png new file mode 100644 index 00000000..e63a9c1d Binary files /dev/null and b/assets/data/img/flags/VC.png differ diff --git a/assets/data/img/flags/VE.png b/assets/data/img/flags/VE.png new file mode 100644 index 00000000..875f7733 Binary files /dev/null and b/assets/data/img/flags/VE.png differ diff --git a/assets/data/img/flags/VG.png b/assets/data/img/flags/VG.png new file mode 100644 index 00000000..0bd002e4 Binary files /dev/null and b/assets/data/img/flags/VG.png differ diff --git a/assets/data/img/flags/VI.png b/assets/data/img/flags/VI.png new file mode 100644 index 00000000..69d667a5 Binary files /dev/null and b/assets/data/img/flags/VI.png differ diff --git a/assets/data/img/flags/VN.png b/assets/data/img/flags/VN.png new file mode 100644 index 00000000..69d87f90 Binary files /dev/null and b/assets/data/img/flags/VN.png differ diff --git a/assets/data/img/flags/VU.png b/assets/data/img/flags/VU.png new file mode 100644 index 00000000..5401c2a6 Binary files /dev/null and b/assets/data/img/flags/VU.png differ diff --git a/assets/data/img/flags/WF.png b/assets/data/img/flags/WF.png new file mode 100644 index 00000000..922b74e2 Binary files /dev/null and b/assets/data/img/flags/WF.png differ diff --git a/assets/data/img/flags/WS.png b/assets/data/img/flags/WS.png new file mode 100644 index 00000000..d1f62df1 Binary files /dev/null and b/assets/data/img/flags/WS.png differ diff --git a/assets/data/img/flags/YE.png b/assets/data/img/flags/YE.png new file mode 100644 index 00000000..bad5e1f4 Binary files /dev/null and b/assets/data/img/flags/YE.png differ diff --git a/assets/data/img/flags/YT.png b/assets/data/img/flags/YT.png new file mode 100644 index 00000000..676e06ca Binary files /dev/null and b/assets/data/img/flags/YT.png differ diff --git a/assets/data/img/flags/ZA.png b/assets/data/img/flags/ZA.png new file mode 100644 index 00000000..701e0106 Binary files /dev/null and b/assets/data/img/flags/ZA.png differ diff --git a/assets/data/img/flags/ZM.png b/assets/data/img/flags/ZM.png new file mode 100644 index 00000000..e3d80780 Binary files /dev/null and b/assets/data/img/flags/ZM.png differ diff --git a/assets/data/img/flags/ZW.png b/assets/data/img/flags/ZW.png new file mode 100644 index 00000000..79864d46 Binary files /dev/null and b/assets/data/img/flags/ZW.png differ diff --git a/assets/data/img/flags/_abkhazia.png b/assets/data/img/flags/_abkhazia.png new file mode 100644 index 00000000..0abf686d Binary files /dev/null and b/assets/data/img/flags/_abkhazia.png differ diff --git a/assets/data/img/flags/_basque-country.png b/assets/data/img/flags/_basque-country.png new file mode 100644 index 00000000..bf2494d2 Binary files /dev/null and b/assets/data/img/flags/_basque-country.png differ diff --git a/assets/data/img/flags/_british-antarctic-territory.png b/assets/data/img/flags/_british-antarctic-territory.png new file mode 100644 index 00000000..b29a7dc2 Binary files /dev/null and b/assets/data/img/flags/_british-antarctic-territory.png differ diff --git a/assets/data/img/flags/_commonwealth.png b/assets/data/img/flags/_commonwealth.png new file mode 100644 index 00000000..8f08c8a0 Binary files /dev/null and b/assets/data/img/flags/_commonwealth.png differ diff --git a/assets/data/img/flags/_england.png b/assets/data/img/flags/_england.png new file mode 100644 index 00000000..7acb112f Binary files /dev/null and b/assets/data/img/flags/_england.png differ diff --git a/assets/data/img/flags/_gosquared.png b/assets/data/img/flags/_gosquared.png new file mode 100644 index 00000000..74f2eb52 Binary files /dev/null and b/assets/data/img/flags/_gosquared.png differ diff --git a/assets/data/img/flags/_kosovo.png b/assets/data/img/flags/_kosovo.png new file mode 100644 index 00000000..dfbb5f01 Binary files /dev/null and b/assets/data/img/flags/_kosovo.png differ diff --git a/assets/data/img/flags/_mars.png b/assets/data/img/flags/_mars.png new file mode 100644 index 00000000..4f5980b7 Binary files /dev/null and b/assets/data/img/flags/_mars.png differ diff --git a/assets/data/img/flags/_nagorno-karabakh.png b/assets/data/img/flags/_nagorno-karabakh.png new file mode 100644 index 00000000..f5a8d271 Binary files /dev/null and b/assets/data/img/flags/_nagorno-karabakh.png differ diff --git a/assets/data/img/flags/_nato.png b/assets/data/img/flags/_nato.png new file mode 100644 index 00000000..fdb05410 Binary files /dev/null and b/assets/data/img/flags/_nato.png differ diff --git a/assets/data/img/flags/_northern-cyprus.png b/assets/data/img/flags/_northern-cyprus.png new file mode 100644 index 00000000..f9bf8bd3 Binary files /dev/null and b/assets/data/img/flags/_northern-cyprus.png differ diff --git a/assets/data/img/flags/_olympics.png b/assets/data/img/flags/_olympics.png new file mode 100644 index 00000000..60452238 Binary files /dev/null and b/assets/data/img/flags/_olympics.png differ diff --git a/assets/data/img/flags/_red-cross.png b/assets/data/img/flags/_red-cross.png new file mode 100644 index 00000000..28636e96 Binary files /dev/null and b/assets/data/img/flags/_red-cross.png differ diff --git a/assets/data/img/flags/_scotland.png b/assets/data/img/flags/_scotland.png new file mode 100644 index 00000000..db580403 Binary files /dev/null and b/assets/data/img/flags/_scotland.png differ diff --git a/assets/data/img/flags/_somaliland.png b/assets/data/img/flags/_somaliland.png new file mode 100644 index 00000000..a903a3b7 Binary files /dev/null and b/assets/data/img/flags/_somaliland.png differ diff --git a/assets/data/img/flags/_south-ossetia.png b/assets/data/img/flags/_south-ossetia.png new file mode 100644 index 00000000..d616841b Binary files /dev/null and b/assets/data/img/flags/_south-ossetia.png differ diff --git a/assets/data/img/flags/_united-nations.png b/assets/data/img/flags/_united-nations.png new file mode 100644 index 00000000..8e45e999 Binary files /dev/null and b/assets/data/img/flags/_united-nations.png differ diff --git a/assets/data/img/flags/_unknown.png b/assets/data/img/flags/_unknown.png new file mode 100644 index 00000000..9d91c7f4 Binary files /dev/null and b/assets/data/img/flags/_unknown.png differ diff --git a/assets/data/img/flags/_wales.png b/assets/data/img/flags/_wales.png new file mode 100644 index 00000000..51f13c2e Binary files /dev/null and b/assets/data/img/flags/_wales.png differ diff --git a/assets/data/img/linux.svg b/assets/data/img/linux.svg new file mode 100755 index 00000000..deed3874 --- /dev/null +++ b/assets/data/img/linux.svg @@ -0,0 +1 @@ + diff --git a/assets/data/img/mac.svg b/assets/data/img/mac.svg new file mode 100755 index 00000000..6ea43ad8 --- /dev/null +++ b/assets/data/img/mac.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/data/img/plants1-br.png b/assets/data/img/plants1-br.png new file mode 100644 index 00000000..7027c2fd Binary files /dev/null and b/assets/data/img/plants1-br.png differ diff --git a/assets/data/img/plants1.png b/assets/data/img/plants1.png new file mode 100644 index 00000000..8e6af5ac Binary files /dev/null and b/assets/data/img/plants1.png differ diff --git a/assets/data/img/spn-feature-carousel/access-regional-content-easily.png b/assets/data/img/spn-feature-carousel/access-regional-content-easily.png new file mode 100644 index 00000000..a90eba8e Binary files /dev/null and b/assets/data/img/spn-feature-carousel/access-regional-content-easily.png differ diff --git a/assets/data/img/spn-feature-carousel/built-from-the-ground-up.png b/assets/data/img/spn-feature-carousel/built-from-the-ground-up.png new file mode 100644 index 00000000..bb8c0d6a Binary files /dev/null and b/assets/data/img/spn-feature-carousel/built-from-the-ground-up.png differ diff --git a/assets/data/img/spn-feature-carousel/bye-bye-vpns.png b/assets/data/img/spn-feature-carousel/bye-bye-vpns.png new file mode 100644 index 00000000..e3bb65a6 Binary files /dev/null and b/assets/data/img/spn-feature-carousel/bye-bye-vpns.png differ diff --git a/assets/data/img/spn-feature-carousel/easily-control-your-privacy.png b/assets/data/img/spn-feature-carousel/easily-control-your-privacy.png new file mode 100644 index 00000000..0d386b5f Binary files /dev/null and b/assets/data/img/spn-feature-carousel/easily-control-your-privacy.png differ diff --git a/assets/data/img/spn-feature-carousel/multiple-identities-for-each-app.png b/assets/data/img/spn-feature-carousel/multiple-identities-for-each-app.png new file mode 100644 index 00000000..b5082f73 Binary files /dev/null and b/assets/data/img/spn-feature-carousel/multiple-identities-for-each-app.png differ diff --git a/assets/data/img/spn-login.png b/assets/data/img/spn-login.png new file mode 100644 index 00000000..4a5541fb Binary files /dev/null and b/assets/data/img/spn-login.png differ diff --git a/assets/data/img/windows.svg b/assets/data/img/windows.svg new file mode 100755 index 00000000..e3e76225 --- /dev/null +++ b/assets/data/img/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/data/world-50m.json b/assets/data/world-50m.json new file mode 100644 index 00000000..4e59231a --- /dev/null +++ b/assets/data/world-50m.json @@ -0,0 +1 @@ +{"type":"Topology","objects":{"land":{"type":"MultiPolygon","arcs":[[[0]],[[1]],[[2]],[[3]],[[4]],[[5]],[[6]],[[7]],[[8]],[[9]],[[10]],[[11]],[[12]],[[13]],[[14]],[[15]],[[16]],[[17]],[[18]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[48]],[[49]],[[50]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[58]],[[59]],[[60]],[[61]],[[62,63]],[[64,65,66]],[[67]],[[68,69,70,71,72]],[[73,74,75,76,77]],[[78]],[[79]],[[80]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]],[[90]],[[91]],[[92]],[[93]],[[94]],[[95]],[[96]],[[97]],[[98,99]],[[100]],[[101]],[[102]],[[103]],[[104]],[[105]],[[106]],[[107]],[[108]],[[109]],[[110]],[[111]],[[112]],[[113]],[[114]],[[115,116]],[[117]],[[118]],[[119]],[[120]],[[121]],[[122]],[[123]],[[124]],[[125]],[[126]],[[127]],[[128]],[[129]],[[130]],[[131]],[[132]],[[133],[134]],[[135]],[[136]],[[137]],[[138]],[[139]],[[140]],[[141]],[[142]],[[143]],[[144]],[[145]],[[146]],[[147]],[[148]],[[149]],[[150]],[[151]],[[152]],[[153]],[[154]],[[155]],[[156]],[[157]],[[158]],[[159]],[[160]],[[161]],[[162]],[[163]],[[164]],[[165]],[[166]],[[167]],[[168]],[[169]],[[170]],[[171]],[[172]],[[173]],[[174]],[[175]],[[176]],[[177]],[[178]],[[179]],[[180]],[[181]],[[182]],[[183]],[[184]],[[185]],[[186]],[[187]],[[188]],[[189]],[[190]],[[191]],[[192]],[[193]],[[194]],[[195]],[[196]],[[197]],[[198]],[[199]],[[200]],[[201]],[[202]],[[203]],[[204]],[[205]],[[206,207,208,209,210,211,212]],[[213]],[[214]],[[215]],[[216]],[[217]],[[218]],[[219]],[[220]],[[221]],[[222]],[[223]],[[224]],[[225]],[[226]],[[227]],[[228]],[[229,230,231,232]],[[233],[234]],[[235]],[[236]],[[237]],[[238]],[[239]],[[240]],[[241]],[[242]],[[243]],[[244]],[[245]],[[246]],[[247]],[[248]],[[249]],[[250]],[[251]],[[252]],[[253]],[[254]],[[255]],[[256]],[[257]],[[258]],[[259]],[[260,261,262,263,264,265,266]],[[267]],[[268,269,270,271]],[[272]],[[273]],[[274,275,276,277,278,279,280,281,282,283]],[[284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306]],[[307]],[[308,309]],[[310]],[[311]],[[312]],[[313]],[[314]],[[315]],[[316]],[[317]],[[318]],[[319]],[[320]],[[321]],[[322]],[[323,324]],[[325]],[[326]],[[327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946],[947],[948],[949],[950],[951],[952,953,954,955],[956],[957,958],[959],[960],[961,962,963,964,965,966],[967],[968],[969],[970],[971],[972],[973],[974],[975,976],[977],[978],[979],[980],[981],[982],[983,984,985,986],[987],[988],[989],[990],[991],[992],[993],[994],[995],[996],[997],[998],[999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014],[1015],[1016],[1017],[1018],[1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029],[1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094],[1095],[1096],[1097],[1098],[1099],[1100],[1101],[1102],[1103],[1104],[1105],[1106],[1107],[1108],[1109],[1110],[1111],[1112],[1113],[1114],[1115],[1116],[1117],[1118],[1119],[1120],[1121],[1122],[1123],[1124],[1125],[1126],[1127],[1128],[1129],[1130],[1131],[1132],[1133],[1134],[1135],[1136],[1137],[1138],[1139],[1140],[1141]],[[1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188,1189,1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1263,1264,1265,1266,1267,1268,1269,1270,1271,1272],[1273,1274,1275,1276,1277],[1278,1279,1280,1281,1282],[1283,1284,1285,1286,1287],[1288],[1289],[1290,1291,1292,1293],[1294,1295,1296,1297,1298,1299],[1300],[1301,1302,1303,1304],[1305],[1306,1307,1308,1309,1310,1311,1312,1313],[1314],[1315,1316,1317],[1318],[1319],[1320],[1321],[1322],[1323],[1324],[1325],[1326],[1327],[1328],[1329],[1330],[1331],[1332],[1333],[1334],[1335],[1336],[1337],[1338],[1339],[1340],[1341],[1342],[1343],[1344],[1345],[1346],[1347],[1348],[1349],[1350],[1351,1352,1353,1354],[1355],[1356],[1357],[1358],[1359],[1360,1361,1362,1363],[1364],[1365],[1366],[1367],[1368],[1369,1370,1371,1372,1373,1374,1375,1376],[1377],[1378],[1379,1380,1381,1382],[1383,1384,1385,1386],[1387],[1388],[1389],[1390],[1391],[1392],[1393],[1394],[1395],[1396,1397,1398,1399],[1400],[1401],[1402],[1403],[1404],[1405],[1406],[1407],[1408],[1409],[1410],[1411],[1412],[1413],[1414],[1415],[1416],[1417],[1418],[1419],[1420],[1421],[1422],[1423],[1424],[1425],[1426],[1427],[1428]],[[1429]],[[1430]],[[1431]],[[1432]],[[1433]],[[1434]],[[1435]],[[1436]],[[1437]],[[1438]],[[1439]],[[1440]],[[1441]],[[1442]],[[1443]],[[1444]],[[1445]],[[1446]],[[1447]],[[1448,1449]],[[1450]],[[1451]],[[1452]],[[1453]],[[1454]],[[1455]],[[1456,1457,1458]],[[1459]],[[1460]],[[1461]],[[1462]],[[1463]],[[1464]],[[1465]],[[1466]],[[1467]],[[1468]],[[1469]],[[1470]],[[1471],[1472]],[[1473,1474,1475,1476,1477,1478]],[[1479]],[[1480,1481,1482,1483,1484,1485,1486,1487,1488,1489]],[[1490,1491,1492,1493,1494,1495,1496,1497]],[[1498]],[[1499]],[[1500,1501]],[[1502]],[[1503,1504,1505]],[[1506]],[[1507,1508]],[[1509,1510,1511,1512]],[[1513,1514,1515,1516]],[[1517,1518,1519,1520]],[[1521,1522,1523]],[[1524,1525,1526]],[[1527,1528,1529,1530,1531,1532,1533]],[[1534]],[[1535]],[[1536,1537,1538,1539,1540]],[[1541]],[[1542]],[[1543]],[[1544,1545]],[[1546]],[[1547,1548,1549,1550,1551,1552]],[[1553]],[[1554]],[[1555,1556,1557,1558,1559,1560,1561,1562]],[[1563,1564,1565]],[[1566,1567,1568]],[[1569]],[[1570,1571,1572]],[[1573,1574,1575]],[[1576]],[[1577,1578,1579,1580,1581,1582,1583,1584,1585,1586,1587]],[[1588]],[[1589,1590,1591,1592,1593,1594]],[[1595,1596,1597,1598,1599]],[[1600]],[[1601,1602,1603]],[[1604,1605,1606]],[[1607]],[[1608]],[[1609]],[[1610]],[[1611]],[[1612]],[[1613,1614,1615,1616]],[[1617,1618,1619,1620,1621]],[[1622]],[[1623,1624,1625]],[[1626,1627,1628]],[[1629]],[[1630,1631,1632]],[[1633]],[[1634,1635,1636]],[[1637,1638,1639,1640,1641,1642,1643,1644,1645,1646,1647,1648,1649,1650,1651,1652,1653,1654,1655,1656,1657,1658,1659,1660,1661,1662,1663,1664]],[[1665]],[[1666]],[[1667,1668,1669,1670,1671,1672,1673,1674,1675,1676,1677,1678,1679,1680,1681,1682,1683,1684,1685,1686,1687,1688]],[[1689]],[[1690,1691,1692,1693,1694,1695,1696,1697,1698,1699,1700,1701,1702]],[[1703,1704,1705]],[[1706,1707,1708]],[[1709]],[[1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720]],[[1721]],[[1722,1723,1724,1725,1726,1727,1728,1729,1730,1731,1732,1733,1734,1735,1736,1737,1738,1739,1740,1741,1742,1743,1744,1745,1746,1747,1748,1749,1750,1751,1752,1753,1754,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1767,1768,1769,1770,1771,1772,1773,1774,1775,1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1786,1787,1788,1789,1790,1791]],[[1792,1793,1794,1795,1796]],[[1797,1798,1799]],[[1800,1801]],[[1802,1803,1804,1805]],[[1806,1807]],[[1808]],[[1809]],[[1810]],[[1811,1812,1813,1814]],[[1815,1816,1817]],[[1818,1819]],[[1820,1821,1822,1823]],[[1824,1825,1826,1827,1828,1829,1830]],[[1831,1832,1833]],[[1834]],[[1835,1836,1837,1838,1839,1840,1841,1842,1843,1844,1845,1846,1847,1848,1849,1850,1851]],[[1852]],[[1853]],[[1854,1855,1856,1857]],[[1858,1859,1860,1861,1862,1863,1864,1865]],[[1866,1867]],[[1868,1869,1870]],[[1871,1872]],[[1873,1874,1875,1876,1877,1878,1879,1880,1881,1882,1883,1884,1885,1886,1887,1888,1889,1890,1891,1892,1893,1894,1895,1896,1897,1898,1899,1900,1901,1902,1903,1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,1914,1915,1916,1917,1918,1919,1920,1921,1922,1923,1924,1925,1926,1927,1928,1929,1930,1931,1932,1933,1934,1935,1936,1937,1938,1939,1940,1941,1942,1943,1944,1945,1946,1947,1948,1949,1950,1951,1952,1953,1954,1955,1956,1957,1958,1959,1960,1961,1962,1963,1964,1965,1966,1967,1968,1969,1970,1971,1972,1973,1974,1975,1976,1977,1978,1979,1980,1981,1982,1983,1984,1985,1986,1987,1988,1989,1990],[1991],[1992]],[[1993]],[[1994,1995]],[[1996]],[[1997,1998,1999,2000]],[[2001]],[[2002]],[[2003]],[[2004]],[[2005,2006,2007,2008,2009,2010,2011]],[[2012,2013]],[[2014]],[[2015,2016]],[[2017]],[[2018]],[[2019]],[[2020]],[[2021]],[[2022]],[[2023]],[[2024]],[[2025]],[[2026]],[[2027,2028,2029,2030,2031,2032,2033,2034,2035,2036,2037,2038,2039,2040,2041,2042,2043,2044,2045,2046,2047,2048,2049,2050,2051,2052,2053,2054,2055,2056,2057,2058,2059,2060,2061,2062,2063,2064,2065,2066,2067,2068,2069,2070,2071,2072,2073]],[[2074,2075,2076,2077,2078,2079,2080,2081,2082,2083,2084,2085,2086,2087,2088,2089,2090]],[[2091,2092,2093,2094,2095,2096,2097,2098,2099,2100,2101,2102,2103,2104,2105,2106,2107,2108,2109,2110,2111,2112]],[[2113]],[[2114]],[[2115,2116,2117,2118]],[[2119]],[[2120,2121,2122,2123,2124,2125,2126,2127,2128,2129,2130]],[[2131,2132,2133,2134,2135,2136,2137,2138]],[[2139]],[[2140]],[[2141]],[[2142,2143,2144,2145,2146,2147,2148,2149,2150,2151,2152,2153,2154,2155,2156]],[[2157,2158,2159,2160,2161,2162,2163,2164,2165,2166,2167,2168,2169,2170,2171,2172,2173,2174,2175,2176,2177,2178,2179,2180,2181,2182,2183,2184,2185,2186,2187,2188,2189,2190,2191,2192,2193,2194,2195,2196],[2197]],[[2198]],[[2199]],[[2200]],[[2201,2202,2203,2204]],[[2205]],[[2206]],[[2207,2208]],[[2209]],[[2210]],[[2211]],[[2212,2213,2214]],[[2215,2216,2217,2218]],[[2219,2220,2221,2222,2223]],[[2224]],[[2225]],[[2226]],[[2227]]]},"countries":{"type":"GeometryCollection","geometries":[{"type":"Polygon","properties":{"admin":"Afghanistan","name":"Afghanistan","postal":"AF","pop_est":28400000,"iso_a2":"AF","iso_a3":"AFG"},"id":4,"arcs":[[2228,2229,2230,2231,2232,2233,2234]]},{"type":"MultiPolygon","properties":{"admin":"Angola","name":"Angola","postal":"AO","pop_est":12799293,"iso_a2":"AO","iso_a3":"AGO"},"id":24,"arcs":[[[2235,2236,1193,2237]],[[1195,2238,2239]]]},{"type":"Polygon","properties":{"admin":"Albania","name":"Albania","postal":"AL","pop_est":3639453,"iso_a2":"AL","iso_a3":"ALB"},"id":8,"arcs":[[2240,2241,2242,1236,2243,2244,1385,2245,2246]]},{"type":"Polygon","properties":{"admin":"United Arab Emirates","name":"United Arab Emirates","postal":"AE","pop_est":4798491,"iso_a2":"AE","iso_a3":"ARE"},"id":784,"arcs":[[1176,2247,2248,1174,2249]]},{"type":"MultiPolygon","properties":{"admin":"Argentina","name":"Argentina","postal":"AR","pop_est":40913584,"iso_a2":"AR","iso_a3":"ARG"},"id":32,"arcs":[[[31]],[[2250,115]],[[2251,2252,2253,670,2254,2255]]]},{"type":"Polygon","properties":{"admin":"Armenia","name":"Armenia","postal":"ARM","pop_est":2967004,"iso_a2":"AM","iso_a3":"ARM"},"id":51,"arcs":[[2256,2257,2258,2259,2260]]},{"type":"MultiPolygon","properties":{"admin":"Antarctica","name":"Antarctica","postal":"AQ","pop_est":3802,"iso_a2":"AQ","iso_a3":"ATA"},"id":10,"arcs":[[[2]],[[7]],[[8]],[[6]],[[5]],[[9]],[[11]],[[3]],[[10]],[[4]],[[0]],[[1]],[[12]],[[16]],[[17]],[[15]],[[14]],[[13]],[[19]],[[20]],[[18]],[[21]],[[22]],[[34]],[[33]],[[36]],[[37]],[[38]],[[35]],[[39]],[[40]],[[41]],[[42]],[[43]],[[32]],[[44]],[[23]],[[25]],[[24]]]},{"type":"Polygon","properties":{"admin":"French Southern and Antarctic Lands","name":"Fr. S. Antarctic Lands","postal":"TF","pop_est":140,"iso_a2":"TF","iso_a3":"ATF"},"id":260,"arcs":[[119]]},{"type":"MultiPolygon","properties":{"admin":"Australia","name":"Australia","postal":"AU","pop_est":21262641,"iso_a2":"AU","iso_a3":"AUS"},"id":36,"arcs":[[[132]],[[127]],[[128]],[[129]],[[135]],[[136]],[[148]],[[239]],[[242]],[[243]],[[233]]]},{"type":"Polygon","properties":{"admin":"Austria","name":"Austria","postal":"A","pop_est":8210281,"iso_a2":"AT","iso_a3":"AUT"},"id":40,"arcs":[[2261,2262,2263,2264,2265,2266,2267,1363,2268,2269,2270]]},{"type":"MultiPolygon","properties":{"admin":"Azerbaijan","name":"Azerbaijan","postal":"AZ","pop_est":8238672,"iso_a2":"AZ","iso_a3":"AZE"},"id":31,"arcs":[[[2271,2272,-2258]],[[1274,2273,-2261,2274,2275]]]},{"type":"Polygon","properties":{"admin":"Burundi","name":"Burundi","postal":"BI","pop_est":8988091,"iso_a2":"BI","iso_a3":"BDI"},"id":108,"arcs":[[2276,2277,1309,2278,2279,2280]]},{"type":"Polygon","properties":{"admin":"Belgium","name":"Belgium","postal":"B","pop_est":10414336,"iso_a2":"BE","iso_a3":"BEL"},"id":56,"arcs":[[2281,2282,2283,2284,2285,1249,2286,2287]]},{"type":"Polygon","properties":{"admin":"Benin","name":"Benin","postal":"BJ","pop_est":8791832,"iso_a2":"BJ","iso_a3":"BEN"},"id":204,"arcs":[[2288,1201,2289,2290,2291]]},{"type":"Polygon","properties":{"admin":"Burkina Faso","name":"Burkina Faso","postal":"BF","pop_est":15746232,"iso_a2":"BF","iso_a3":"BFA"},"id":854,"arcs":[[2292,-2291,2293,2294,2295,2296]]},{"type":"MultiPolygon","properties":{"admin":"Bangladesh","name":"Bangladesh","postal":"BD","pop_est":156050883,"iso_a2":"BD","iso_a3":"BGD"},"id":50,"arcs":[[[1430]],[[2297,1164,2298]]]},{"type":"Polygon","properties":{"admin":"Bulgaria","name":"Bulgaria","postal":"BG","pop_est":7204687,"iso_a2":"BG","iso_a3":"BGR"},"id":100,"arcs":[[1233,2299,2300,2301,2302,2303]]},{"type":"MultiPolygon","properties":{"admin":"The Bahamas","name":"Bahamas","postal":"BS","pop_est":309156,"iso_a2":"BS","iso_a3":"BHS"},"id":44,"arcs":[[[67]],[[1429]],[[1431]],[[1437]],[[1434]],[[1433]]]},{"type":"Polygon","properties":{"admin":"Bosnia and Herzegovina","name":"Bosnia and Herz.","postal":"BiH","pop_est":4613414,"iso_a2":"BA","iso_a3":"BIH"},"id":70,"arcs":[[2304,2305,2306,1239,2307]]},{"type":"Polygon","properties":{"admin":"Belarus","name":"Belarus","postal":"BY","pop_est":9648533,"iso_a2":"BY","iso_a3":"BLR"},"id":112,"arcs":[[2308,2309,2310,2311,2312]]},{"type":"Polygon","properties":{"admin":"Belize","name":"Belize","postal":"BZ","pop_est":307899,"iso_a2":"BZ","iso_a3":"BLZ"},"id":84,"arcs":[[2313,2314,657]]},{"type":"Polygon","properties":{"admin":"Bolivia","name":"Bolivia","postal":"BO","pop_est":9775246,"iso_a2":"BO","iso_a3":"BOL"},"id":68,"arcs":[[2315,-2256,2316,2317,2318,964,2319,2320,2321]]},{"type":"MultiPolygon","properties":{"admin":"Brazil","name":"Brazil","postal":"BR","pop_est":198739269,"iso_a2":"BR","iso_a3":"BRA"},"id":76,"arcs":[[[193]],[[194]],[[156]],[[247]],[[249]],[[251]],[[2322,2323,668,2324,2325,955,2326,2327,-2253,2328,2329,2330,-2322,2331,2332,2333,2334]]]},{"type":"MultiPolygon","properties":{"admin":"Brunei","name":"Brunei","postal":"BN","pop_est":388190,"iso_a2":"BN","iso_a3":"BRN"},"id":96,"arcs":[[[265,2335]],[[2336,2337,264]]]},{"type":"Polygon","properties":{"admin":"Bhutan","name":"Bhutan","postal":"BT","pop_est":691141,"iso_a2":"BT","iso_a3":"BTN"},"id":64,"arcs":[[2338,2339]]},{"type":"Polygon","properties":{"admin":"Botswana","name":"Botswana","postal":"BW","pop_est":1990876,"iso_a2":"BW","iso_a3":"BWA"},"id":72,"arcs":[[2340,2341,2342]]},{"type":"Polygon","properties":{"admin":"Central African Republic","name":"Central African Rep.","postal":"CF","pop_est":4511488,"iso_a2":"CF","iso_a3":"CAF"},"id":140,"arcs":[[2343,2344,2345,2346,2347,2348]]},{"type":"MultiPolygon","properties":{"admin":"Canada","name":"Canada","postal":"CA","pop_est":33487208,"iso_a2":"CA","iso_a3":"CAN"},"id":124,"arcs":[[[1500,1501]],[[2216,2217,2218,2215]],[[1490,1491,1492,1493,1494,1495,1496,1497]],[[1480,1481,1482,1483,1484,1485,1486,1487,1488,1489]],[[500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,2349,2350,2351,986,2352,2353,2354,2355,2356,2357,2358,2359]],[[1473,1474,1475,1476,1477,1478]],[[2142,2143,2144,2145,2146,2147,2148,2149,2150,2151,2152,2153,2154,2155,2156]],[[2157,2158,2159,2160,2161,2162,2163,2164,2165,2166,2167,2168,2169,2170,2171,2172,2173,2174,2175,2176,2177,2178,2179,2180,2181,2182,2183,2184,2185,2186,2187,2188,2189,2190,2191,2192,2193,2194,2195,2196]],[[2201,2202,2203,2204]],[[1513,1514,1515,1516]],[[1517,1518,1519,1520]],[[1524,1525,1526]],[[1527,1528,1529,1530,1531,1532,1533]],[[1563,1564,1565]],[[1994,1995]],[[1800,1801]],[[1854,1855,1856,1857]],[[1820,1821,1822,1823]],[[1858,1859,1860,1861,2360,1863,1864,1865]],[[1831,1832,1833]],[[1834]],[[1835,1836,1837,1838,1839,1840,1841,1842,1843,1844,1845,1846,1847,1848,1849,1850,1851]],[[1868,1869,1870]],[[1871,1872]],[[1806,1807]],[[1815,1816,1817]],[[1802,1803,1804,1805]],[[1818,1819]],[[1811,1812,1813,1814]],[[2012,2013]],[[2015,2016]],[[2005,2006,2007,2008,2009,2010,2011]],[[2361,2362,2363,1000,1001,1002,1003,1004,1005,1006,1007,2364,2365,1027,1028,1029,1019,1020,2366,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,2367,2368,2369,2370,2371,2372,2373,2374,2375,2376,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,2377,2378,2379,2380,2381,2382,2383,2384,2385,2386,2387,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499]],[[2027,2028,2029,2030,2031,2032,2033,2034,2035,2036,2037,2038,2039,2040,2041,2042,2043,2044,2045,2046,2047,2048,2049,2050,2051,2052,2053,2054,2055,2056,2057,2058,2059,2060,2061,2062,2063,2064,2065,2066,2067,2068,2069,2070,2071,2072,2073]],[[2115,2116,2117,2118]],[[2131,2132,2133,2134,2135,2136,2137,2138]],[[1873,1874,1875,1876,1877,1878,1879,1880,1881,1882,1883,1884,1885,1886,1887,1888,1889,1890,1891,1892,1893,1894,1895,1896,1897,1898,1899,1900,1901,1902,1903,1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,1914,1915,1916,1917,1918,1919,1920,1921,1922,1923,1924,1925,1926,1927,1928,1929,1930,1931,1932,1933,1934,1935,1936,1937,1938,1939,1940,1941,1942,1943,1944,1945,1946,1947,1948,1949,1950,1951,1952,1953,1954,1955,1956,1957,1958,1959,1960,1961,1962,1963,1964,1965,1966,1967,1968,1969,1970,1971,1972,1973,1974,1975,1976,1977,1978,1979,1980,1981,1982,1983,1984,1985,1986,1987,1988,1989,1990]],[[2091,2092,2093,2094,2095,2096,2097,2098,2099,2100,2101,2102,2103,2104,2105,2106,2107,2108,2109,2110,2111,2112]],[[2026]],[[2120,2121,2122,2123,2124,2125,2126,2127,2128,2129,2130]],[[2074,2075,2076,2077,2078,2079,2080,2081,2082,2083,2084,2085,2086,2087,2088,2089,2090]],[[1613,1614,1615,1616]],[[1617,1618,1619,1620,1621]],[[1689]],[[1703,1704,1705]],[[1706,1707,1708]],[[1690,1691,1692,1693,1694,1695,1696,1697,1698,1699,1700,1701,1702]],[[1637,1638,1639,1640,1641,1642,1643,1644,1645,1646,1647,1648,1649,1650,1651,1652,1653,1654,1655,1656,1657,1658,1659,1660,1661,1662,1663,1664]],[[1667,1668,1669,1670,1671,1672,1673,1674,1675,1676,1677,1678,1679,1680,1681,1682,1683,1684,1685,1686,1687,1688]],[[1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720]],[[1626,1627,1628]],[[1633]],[[1634,1635,1636]],[[1623,1624,1625]],[[1630,1631,1632]],[[267]],[[268,269,270,271]],[[274,275,276,277,278,279,280,281,282,283]],[[308,309]],[[284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306]],[[1722,1723,1724,1725,1726,1727,1728,1729,1730,1731,1732,1733,1734,1735,1736,1737,1738,1739,1740,1741,1742,1743,1744,1745,1746,1747,1748,1749,1750,1751,1752,1753,1754,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1767,1768,1769,1770,1771,1772,1773,1774,1775,1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1786,1787,1788,1789,1790,1791]]]},{"type":"Polygon","properties":{"admin":"Switzerland","name":"Switzerland","postal":"CH","pop_est":7604467,"iso_a2":"CH","iso_a3":"CHE"},"id":756,"arcs":[[1362,-2268,2388,-2266,2389,2390,2391,2392,2393]]},{"type":"MultiPolygon","properties":{"admin":"Chile","name":"Chile","postal":"CL","pop_est":16601707,"iso_a2":"CL","iso_a3":"CHL"},"id":152,"arcs":[[[30]],[[29]],[[28]],[[27]],[[114]],[[113]],[[-2251,116]],[[149]],[[121]],[[117]],[[122]],[[120]],[[118]],[[123]],[[125]],[[124]],[[131]],[[-2255,671,2394,-2317]]]},{"type":"MultiPolygon","properties":{"admin":"China","name":"China","postal":"CN","pop_est":1338612970,"iso_a2":"CN","iso_a3":"CHN"},"id":156,"arcs":[[[101]],[[1441]],[[2395,1352,2396,2397,2398,1152,2399,1155,2400,1157,2401,2402,2403,2404,-2340,2405,2406,2407,2408,2409,2410,2411,2412,2413,2414,2415,2416,2417,2418,2419,-2229,2420,2421,2422,2423,2424,2425]]]},{"type":"Polygon","properties":{"admin":"Ivory Coast","name":"Côte d'Ivoire","postal":"CI","pop_est":20617068,"iso_a2":"CI","iso_a3":"CIV"},"id":384,"arcs":[[-2296,2426,1206,2427,2428,2429]]},{"type":"Polygon","properties":{"admin":"Cameroon","name":"Cameroon","postal":"CM","pop_est":18879301,"iso_a2":"CM","iso_a3":"CMR"},"id":120,"arcs":[[-2348,2430,2431,2432,1199,2433,2434,2435]]},{"type":"Polygon","properties":{"admin":"Democratic Republic of the Congo","name":"Dem. Rep. Congo","postal":"DRC","pop_est":68692542,"iso_a2":"CD","iso_a3":"COD"},"id":180,"arcs":[[2436,2437,2438,1316,2439,2440,1304,2441,2442,2443,2444,1290,2445,2446,-2280,2447,1311,2448,2449,-2238,1194,-2240,2450,-2346]]},{"type":"Polygon","properties":{"admin":"Republic of Congo","name":"Congo","postal":"CG","pop_est":4012809,"iso_a2":"CG","iso_a3":"COG"},"id":178,"arcs":[[-2451,-2239,1196,2451,-2431,-2347]]},{"type":"Polygon","properties":{"admin":"Colombia","name":"Colombia","postal":"CO","pop_est":45644023,"iso_a2":"CO","iso_a3":"COL"},"id":170,"arcs":[[2452,-2333,2453,2454,674,2455,663]]},{"type":"MultiPolygon","properties":{"admin":"Comoros","name":"Comoros","postal":"KM","pop_est":752438,"iso_a2":"KM","iso_a3":"COM"},"id":174,"arcs":[[[238]],[[240]]]},{"type":"MultiPolygon","properties":{"admin":"Cape Verde","name":"Cape Verde","postal":"CV","pop_est":429474,"iso_a2":"CV","iso_a3":"CPV"},"id":132,"arcs":[[[81]],[[83]],[[86]],[[85]]]},{"type":"Polygon","properties":{"admin":"Costa Rica","name":"Costa Rica","postal":"CR","pop_est":4253877,"iso_a2":"CR","iso_a3":"CRI"},"id":188,"arcs":[[661,2456,676,2457]]},{"type":"MultiPolygon","properties":{"admin":"Cuba","name":"Cuba","postal":"CU","pop_est":11451652,"iso_a2":"CU","iso_a3":"CUB"},"id":192,"arcs":[[[325]],[[326]]]},{"type":"Polygon","properties":{"admin":"Northern Cyprus","name":"N. Cyprus","postal":"CN","pop_est":265100,"iso_a2":"-99","iso_a3":"-99"},"id":-99,"arcs":[[2458,1449]]},{"type":"Polygon","properties":{"admin":"Cyprus","name":"Cyprus","postal":"CY","pop_est":531640,"iso_a2":"CY","iso_a3":"CYP"},"id":196,"arcs":[[-2459,1448]]},{"type":"Polygon","properties":{"admin":"Czech Republic","name":"Czech Rep.","postal":"CZ","pop_est":10211904,"iso_a2":"CZ","iso_a3":"CZE"},"id":203,"arcs":[[2459,2460,-2271,2461]]},{"type":"MultiPolygon","properties":{"admin":"Germany","name":"Germany","postal":"D","pop_est":82329758,"iso_a2":"DE","iso_a3":"DEU"},"id":276,"arcs":[[[2206]],[[2462,1256,2463,2464,-2462,-2270,2465,1361,-2394,-2393,-2392,2466,2467,-2284,2468,1252,2469,2470]]]},{"type":"Polygon","properties":{"admin":"Djibouti","name":"Djibouti","postal":"DJ","pop_est":516055,"iso_a2":"DJ","iso_a3":"DJI"},"id":262,"arcs":[[2471,2472,2473,1185]]},{"type":"Polygon","properties":{"admin":"Dominica","name":"Dominica","postal":"DM","pop_est":72660,"iso_a2":"DM","iso_a3":"DMA"},"id":212,"arcs":[[80]]},{"type":"MultiPolygon","properties":{"admin":"Denmark","name":"Denmark","postal":"DK","pop_est":5500510,"iso_a2":"DK","iso_a3":"DNK"},"id":208,"arcs":[[[1534]],[[1542]],[[1546]],[[1543]],[[2474,-2471,2475,1254]]]},{"type":"Polygon","properties":{"admin":"Dominican Republic","name":"Dominican Rep.","postal":"DO","pop_est":9650054,"iso_a2":"DO","iso_a3":"DOM"},"id":214,"arcs":[[2476,98]]},{"type":"Polygon","properties":{"admin":"Algeria","name":"Algeria","postal":"DZ","pop_est":34178188,"iso_a2":"DZ","iso_a3":"DZA"},"id":12,"arcs":[[2477,2478,2479,2480,2481,2482,2483,1220]]},{"type":"MultiPolygon","properties":{"admin":"Ecuador","name":"Ecuador","postal":"EC","pop_est":14573101,"iso_a2":"EC","iso_a3":"ECU"},"id":218,"arcs":[[[173]],[[195]],[[188]],[[2484,673,-2455]]]},{"type":"Polygon","properties":{"admin":"Egypt","name":"Egypt","postal":"EG","pop_est":83082869,"iso_a2":"EG","iso_a3":"EGY"},"id":818,"arcs":[[2485,2486,1182,2487,2488,1223]]},{"type":"MultiPolygon","properties":{"admin":"Eritrea","name":"Eritrea","postal":"ER","pop_est":5647168,"iso_a2":"ER","iso_a3":"ERI"},"id":232,"arcs":[[[1184,-2474,2489,2490]]]},{"type":"MultiPolygon","properties":{"admin":"Spain","name":"Spain","postal":"E","pop_est":40525002,"iso_a2":"ES","iso_a3":"ESP"},"id":724,"arcs":[[[1443]],[[1445]],[[1446]],[[1438]],[[1460]],[[1465]],[[2491,-2493,2493,1245,2494,1247]]]},{"type":"MultiPolygon","properties":{"admin":"Estonia","name":"Estonia","postal":"EST","pop_est":1299371,"iso_a2":"EE","iso_a3":"EST"},"id":233,"arcs":[[[1609]],[[1554]],[[2495,2496,1397,2497,2498,2499,1268]]]},{"type":"Polygon","properties":{"admin":"Ethiopia","name":"Ethiopia","postal":"ET","pop_est":85237338,"iso_a2":"ET","iso_a3":"ETH"},"id":231,"arcs":[[-2473,2500,2501,2502,2503,2504,-2490]]},{"type":"Polygon","properties":{"admin":"Finland","name":"Finland","postal":"FIN","pop_est":5250275,"iso_a2":"FI","iso_a3":"FIN"},"id":246,"arcs":[[2505,1270,2506,2507]]},{"type":"MultiPolygon","properties":{"admin":"Fiji","name":"Fiji","postal":"FJ","pop_est":944720,"iso_a2":"FJ","iso_a3":"FJI"},"id":242,"arcs":[[[142]],[[146]]]},{"type":"MultiPolygon","properties":{"admin":"Falkland Islands","name":"Falkland Is.","postal":"FK","pop_est":3140,"iso_a2":"FK","iso_a3":"FLK"},"id":238,"arcs":[[[151]],[[150]]]},{"type":"MultiPolygon","properties":{"admin":"France","name":"France","postal":"F","pop_est":64057792,"iso_a2":"FR","iso_a3":"FRA"},"id":250,"arcs":[[[139]],[[-2324,2508,667]],[[87]],[[82]],[[84]],[[1459]],[[2509,-2467,-2391,2510,2511,1244,-2494,-2513,-2492,1248,-2286]]]},{"type":"Polygon","properties":{"admin":"Faroe Islands","name":"Faeroe Is.","postal":"FO","pop_est":48856,"iso_a2":"FO","iso_a3":"FRO"},"id":234,"arcs":[[1853]]},{"type":"Polygon","properties":{"admin":"Gabon","name":"Gabon","postal":"GA","pop_est":1514993,"iso_a2":"GA","iso_a3":"GAB"},"id":266,"arcs":[[-2452,1197,2513,-2432]]},{"type":"MultiPolygon","properties":{"admin":"United Kingdom","name":"United Kingdom","postal":"GB","pop_est":62262000,"iso_a2":"GB","iso_a3":"GBR"},"id":826,"arcs":[[[2205]],[[2514,2208]],[[1553]],[[1569]],[[1600]],[[1607]],[[2209]],[[1993]]]},{"type":"Polygon","properties":{"admin":"Georgia","name":"Georgia","postal":"GE","pop_est":4615807,"iso_a2":"GE","iso_a3":"GEO"},"id":268,"arcs":[[-2275,-2260,2515,1229,2516]]},{"type":"Polygon","properties":{"admin":"Ghana","name":"Ghana","postal":"GH","pop_est":23832495,"iso_a2":"GH","iso_a3":"GHA"},"id":288,"arcs":[[2517,1203,2518,1205,-2427,-2295]]},{"type":"Polygon","properties":{"admin":"Guinea","name":"Guinea","postal":"GN","pop_est":10057975,"iso_a2":"GN","iso_a3":"GIN"},"id":324,"arcs":[[2519,-2429,2520,2521,1209,2522,2523]]},{"type":"Polygon","properties":{"admin":"Gambia","name":"Gambia","postal":"GM","pop_est":1782893,"iso_a2":"GM","iso_a3":"GMB"},"id":270,"arcs":[[1212,2524]]},{"type":"Polygon","properties":{"admin":"Guinea Bissau","name":"Guinea-Bissau","postal":"GW","pop_est":1533964,"iso_a2":"GW","iso_a3":"GNB"},"id":624,"arcs":[[1210,2525,-2523]]},{"type":"MultiPolygon","properties":{"admin":"Equatorial Guinea","name":"Eq. Guinea","postal":"GQ","pop_est":650702,"iso_a2":"GQ","iso_a3":"GNQ"},"id":226,"arcs":[[[1198,-2433,-2514]],[[106]]]},{"type":"MultiPolygon","properties":{"admin":"Greece","name":"Greece","postal":"GR","pop_est":10737428,"iso_a2":"GR","iso_a3":"GRC"},"id":300,"arcs":[[[1451]],[[1447]],[[1454]],[[1455]],[[1462]],[[1463]],[[1461]],[[1464]],[[1235,-2243,2526,-2301,2527]]]},{"type":"MultiPolygon","properties":{"admin":"Greenland","name":"Greenland","postal":"GL","pop_est":57600,"iso_a2":"GL","iso_a3":"GRL"},"id":304,"arcs":[[[2017]],[[2018]],[[2003]],[[2025]],[[1622]],[[1709]],[[307]],[[312]],[[2113]]]},{"type":"Polygon","properties":{"admin":"Guatemala","name":"Guatemala","postal":"GT","pop_est":13276517,"iso_a2":"GT","iso_a3":"GTM"},"id":320,"arcs":[[-2314,658,2528,2529,680,2530]]},{"type":"Polygon","properties":{"admin":"Guam","name":"Guam","postal":"GU","pop_est":178430,"iso_a2":"GU","iso_a3":"GUM"},"id":316,"arcs":[[88]]},{"type":"Polygon","properties":{"admin":"Guyana","name":"Guyana","postal":"GY","pop_est":772298,"iso_a2":"GY","iso_a3":"GUY"},"id":328,"arcs":[[2531,-2335,2532,665]]},{"type":"Polygon","properties":{"admin":"Hong Kong S.A.R.","name":"Hong Kong","postal":"HK","pop_est":7061200,"iso_a2":"HK","iso_a3":"HKG"},"id":344,"arcs":[[-2400,2533,1154]]},{"type":"Polygon","properties":{"admin":"Honduras","name":"Honduras","postal":"HN","pop_est":7792854,"iso_a2":"HN","iso_a3":"HND"},"id":340,"arcs":[[2534,678,2535,-2529,659]]},{"type":"MultiPolygon","properties":{"admin":"Croatia","name":"Croatia","postal":"HR","pop_est":4489409,"iso_a2":"HR","iso_a3":"HRV"},"id":191,"arcs":[[[-2307,2536,1238]],[[2537,-2308,1240,2538,2539]]]},{"type":"MultiPolygon","properties":{"admin":"Haiti","name":"Haiti","postal":"HT","pop_est":9035536,"iso_a2":"HT","iso_a3":"HTI"},"id":332,"arcs":[[[-2477,99]]]},{"type":"Polygon","properties":{"admin":"Hungary","name":"Hungary","postal":"HU","pop_est":9905596,"iso_a2":"HU","iso_a3":"HUN"},"id":348,"arcs":[[2540,2541,2542,-2540,2543,-2263,2544]]},{"type":"MultiPolygon","properties":{"admin":"Indonesia","name":"Indonesia","postal":"INDO","pop_est":240271522,"iso_a2":"ID","iso_a3":"IDN"},"id":360,"arcs":[[[245]],[[203]],[[2545,207,2546,2547,2548,210,2549,2550]],[[214]],[[219]],[[218]],[[223]],[[220]],[[216]],[[213]],[[217]],[[225]],[[226]],[[228]],[[153]],[[155]],[[227]],[[164]],[[167]],[[169]],[[157]],[[171]],[[158]],[[162]],[[159]],[[160]],[[175]],[[176]],[[177]],[[178]],[[179]],[[182]],[[181]],[[183]],[[180]],[[185]],[[184]],[[187]],[[190]],[[189]],[[192]],[[191]],[[2551,2552,2553,2554,232]],[[197]],[[196]],[[198]],[[248]],[[253]],[[254]],[[255]],[[256]],[[252]],[[257]],[[186]],[[102]],[[259]],[[103]],[[104]],[[107]],[[2555,261,2556]],[[105]],[[258]]]},{"type":"Polygon","properties":{"admin":"Isle of Man","name":"Isle of Man","postal":"IM","pop_est":76512,"iso_a2":"IM","iso_a3":"IMN"},"id":833,"arcs":[[2200]]},{"type":"MultiPolygon","properties":{"admin":"India","name":"India","postal":"IND","pop_est":1166079220,"iso_a2":"IN","iso_a3":"IND"},"id":356,"arcs":[[[111]],[[52]],[[92]],[[-2412,2557,-2410,2558,-2408,2559,-2406,-2339,-2405,2560,-2299,1165,2561,2562,2563,-2417,2564,-2415,2565,-2413]]]},{"type":"Polygon","properties":{"admin":"Ireland","name":"Ireland","postal":"IRL","pop_est":4203200,"iso_a2":"IE","iso_a3":"IRL"},"id":372,"arcs":[[2207,-2515]]},{"type":"MultiPolygon","properties":{"admin":"Iran","name":"Iran","postal":"IRN","pop_est":66429284,"iso_a2":"IR","iso_a3":"IRN"},"id":364,"arcs":[[[1435]],[[-2257,-2274,1275,2566,-2232,2567,2568,2569,1168,2570,2571,-2272]]]},{"type":"Polygon","properties":{"admin":"Iraq","name":"Iraq","postal":"IRQ","pop_est":31129225,"iso_a2":"IQ","iso_a3":"IRQ"},"id":368,"arcs":[[-2571,1169,2572,2573,2574,2575,2576]]},{"type":"Polygon","properties":{"admin":"Iceland","name":"Iceland","postal":"IS","pop_est":306694,"iso_a2":"IS","iso_a3":"ISL"},"id":352,"arcs":[[1852]]},{"type":"Polygon","properties":{"admin":"Israel","name":"Israel","postal":"IS","pop_est":7233701,"iso_a2":"IL","iso_a3":"ISR"},"id":376,"arcs":[[2577,2578,2579,2580,2581,1181,-2487,2582,1225,2583,2584]]},{"type":"MultiPolygon","properties":{"admin":"Italy","name":"Italy","postal":"I","pop_est":58126212,"iso_a2":"IT","iso_a3":"ITA"},"id":380,"arcs":[[[1452]],[[1466]],[[2585,1242,-2511,-2390,-2265]]]},{"type":"Polygon","properties":{"admin":"Jamaica","name":"Jamaica","postal":"J","pop_est":2825928,"iso_a2":"JM","iso_a3":"JAM"},"id":388,"arcs":[[97]]},{"type":"Polygon","properties":{"admin":"Jordan","name":"Jordan","postal":"J","pop_est":6342948,"iso_a2":"JO","iso_a3":"JOR"},"id":400,"arcs":[[2586,1180,-2582,-2581,-2580,2587,2588,-2578,2589,-2575]]},{"type":"MultiPolygon","properties":{"admin":"Japan","name":"Japan","postal":"J","pop_est":127078679,"iso_a2":"JP","iso_a3":"JPN"},"id":392,"arcs":[[[1432]],[[1444]],[[1467]],[[1470]],[[1450]],[[1453]],[[1471]],[[1499]]]},{"type":"Polygon","properties":{"admin":"Siachen Glacier","name":"Siachen Glacier","postal":"SG","pop_est":6000,"iso_a2":"-99","iso_a3":"-99"},"id":-99,"arcs":[[-2563,2590,-2419]]},{"type":"MultiPolygon","properties":{"admin":"Kazakhstan","name":"Kazakhstan","postal":"KZ","pop_est":15399437,"iso_a2":"KZ","iso_a3":"KAZ"},"id":398,"arcs":[[[2591,2592,2593,1372]],[[-2423,2594,2595,2596,1376,2597,2598,2599,1277,2600]]]},{"type":"Polygon","properties":{"admin":"Kenya","name":"Kenya","postal":"KE","pop_est":39002772,"iso_a2":"KE","iso_a3":"KEN"},"id":404,"arcs":[[2601,1188,2602,2603,1299,2604,2605,2606,-2503]]},{"type":"Polygon","properties":{"admin":"Kyrgyzstan","name":"Kyrgyzstan","postal":"KG","pop_est":5431747,"iso_a2":"KG","iso_a3":"KGZ"},"id":417,"arcs":[[-2422,2607,2608,-2595]]},{"type":"Polygon","properties":{"admin":"Cambodia","name":"Cambodia","postal":"KH","pop_est":14494293,"iso_a2":"KH","iso_a3":"KHM"},"id":116,"arcs":[[1159,2609,2610,2611]]},{"type":"MultiPolygon","properties":{"admin":"South Korea","name":"Korea","postal":"KR","pop_est":48508972,"iso_a2":"KR","iso_a3":"KOR"},"id":410,"arcs":[[[1469]],[[1150,2612]]]},{"type":"Polygon","properties":{"admin":"Kosovo","name":"Kosovo","postal":"KO","pop_est":1804838,"iso_a2":"-99","iso_a3":"-99"},"id":-99,"arcs":[[2613,-2241,2614,2615]]},{"type":"MultiPolygon","properties":{"admin":"Kuwait","name":"Kuwait","postal":"KW","pop_est":2691158,"iso_a2":"KW","iso_a3":"KWT"},"id":414,"arcs":[[[1442]],[[2616,-2573,1170]]]},{"type":"Polygon","properties":{"admin":"Laos","name":"Lao PDR","postal":"LA","pop_est":6834942,"iso_a2":"LA","iso_a3":"LAO"},"id":418,"arcs":[[2617,-2611,2618,2619,-2403]]},{"type":"Polygon","properties":{"admin":"Lebanon","name":"Lebanon","postal":"LB","pop_est":4017095,"iso_a2":"LB","iso_a3":"LBN"},"id":422,"arcs":[[-2584,1226,2620]]},{"type":"Polygon","properties":{"admin":"Liberia","name":"Liberia","postal":"LR","pop_est":3441790,"iso_a2":"LR","iso_a3":"LBR"},"id":430,"arcs":[[-2428,1207,2621,-2521]]},{"type":"Polygon","properties":{"admin":"Libya","name":"Libya","postal":"LY","pop_est":6310434,"iso_a2":"LY","iso_a3":"LBY"},"id":434,"arcs":[[-2489,2622,2623,2624,-2479,2625,1222]]},{"type":"Polygon","properties":{"admin":"Sri Lanka","name":"Sri Lanka","postal":"LK","pop_est":21324791,"iso_a2":"LK","iso_a3":"LKA"},"id":144,"arcs":[[108]]},{"type":"Polygon","properties":{"admin":"Lesotho","name":"Lesotho","postal":"LS","pop_est":2130819,"iso_a2":"LS","iso_a3":"LSO"},"id":426,"arcs":[[2626]]},{"type":"Polygon","properties":{"admin":"Lithuania","name":"Lithuania","postal":"LT","pop_est":3555179,"iso_a2":"LT","iso_a3":"LTU"},"id":440,"arcs":[[-2312,2627,2628,2629,1266,2630]]},{"type":"Polygon","properties":{"admin":"Luxembourg","name":"Luxembourg","postal":"L","pop_est":491775,"iso_a2":"LU","iso_a3":"LUX"},"id":442,"arcs":[[-2468,-2510,-2285]]},{"type":"Polygon","properties":{"admin":"Latvia","name":"Latvia","postal":"LV","pop_est":2231503,"iso_a2":"LV","iso_a3":"LVA"},"id":428,"arcs":[[2631,-2313,-2631,1267,-2500]]},{"type":"Polygon","properties":{"admin":"Morocco","name":"Morocco","postal":"MA","pop_est":34859364,"iso_a2":"MA","iso_a3":"MAR"},"id":504,"arcs":[[-2484,2632,2633,1217,2634,1219]]},{"type":"Polygon","properties":{"admin":"Moldova","name":"Moldova","postal":"MD","pop_est":4320748,"iso_a2":"MD","iso_a3":"MDA"},"id":498,"arcs":[[2635,2636]]},{"type":"Polygon","properties":{"admin":"Madagascar","name":"Madagascar","postal":"MG","pop_est":20653556,"iso_a2":"MG","iso_a3":"MDG"},"id":450,"arcs":[[235]]},{"type":"MultiPolygon","properties":{"admin":"Mexico","name":"Mexico","postal":"MX","pop_est":111211789,"iso_a2":"MX","iso_a3":"MEX"},"id":484,"arcs":[[[1439]],[[1440]],[[2637,2638,2639,2640,2641,2642,2643,2644,2645,2646,2647,2648,2649,2650,2651,2652,2653,2654,656,-2315,-2531,681,2655,2656,2657]]]},{"type":"Polygon","properties":{"admin":"Macedonia","name":"Macedonia","postal":"MK","pop_est":2066718,"iso_a2":"MK","iso_a3":"MKD"},"id":807,"arcs":[[-2302,-2527,-2242,-2614,2658]]},{"type":"Polygon","properties":{"admin":"Mali","name":"Mali","postal":"ML","pop_est":12666987,"iso_a2":"ML","iso_a3":"MLI"},"id":466,"arcs":[[2659,-2297,-2430,-2520,2660,2661,-2481]]},{"type":"MultiPolygon","properties":{"admin":"Myanmar","name":"Myanmar","postal":"MM","pop_est":48137741,"iso_a2":"MM","iso_a3":"MMR"},"id":104,"arcs":[[[-2620,2662,1163,-2298,-2561,-2404]]]},{"type":"Polygon","properties":{"admin":"Montenegro","name":"Montenegro","postal":"ME","pop_est":672180,"iso_a2":"ME","iso_a3":"MNE"},"id":499,"arcs":[[2663,-2615,-2247,2664,1383,2665,-2244,1237,-2537,-2306]]},{"type":"Polygon","properties":{"admin":"Mongolia","name":"Mongolia","postal":"MN","pop_est":3041142,"iso_a2":"MN","iso_a3":"MNG"},"id":496,"arcs":[[-2425,2666]]},{"type":"Polygon","properties":{"admin":"Mozambique","name":"Mozambique","postal":"MZ","pop_est":21669278,"iso_a2":"MZ","iso_a3":"MOZ"},"id":508,"arcs":[[2667,2668,2669,2670,2671,2672,2673,1284,2674,2675,1190]]},{"type":"Polygon","properties":{"admin":"Mauritania","name":"Mauritania","postal":"MR","pop_est":3129486,"iso_a2":"MR","iso_a3":"MRT"},"id":478,"arcs":[[2676,1214,2677,-2482,-2662]]},{"type":"Polygon","properties":{"admin":"Mauritius","name":"Mauritius","postal":"MU","pop_est":1284264,"iso_a2":"MU","iso_a3":"MUS"},"id":480,"arcs":[[141]]},{"type":"Polygon","properties":{"admin":"Malawi","name":"Malawi","postal":"MW","pop_est":14268711,"iso_a2":"MW","iso_a3":"MWI"},"id":454,"arcs":[[1287,2678,-2673,2679,2680]]},{"type":"MultiPolygon","properties":{"admin":"Malaysia","name":"Malaysia","postal":"MY","pop_est":25715819,"iso_a2":"MY","iso_a3":"MYS"},"id":458,"arcs":[[[1161,2681]],[[2682,-2557,262,2683,-2337,-2336,266]]]},{"type":"Polygon","properties":{"admin":"Namibia","name":"Namibia","postal":"NA","pop_est":2108665,"iso_a2":"NA","iso_a3":"NAM"},"id":516,"arcs":[[2684,-2343,2685,1192,-2237]]},{"type":"MultiPolygon","properties":{"admin":"New Caledonia","name":"New Caledonia","postal":"NC","pop_est":227436,"iso_a2":"NC","iso_a3":"NCL"},"id":540,"arcs":[[[140]],[[138]]]},{"type":"Polygon","properties":{"admin":"Niger","name":"Niger","postal":"NE","pop_est":15306252,"iso_a2":"NE","iso_a3":"NER"},"id":562,"arcs":[[2686,2687,-2292,-2293,-2660,-2480,-2625]]},{"type":"Polygon","properties":{"admin":"Nigeria","name":"Nigeria","postal":"NG","pop_est":149229090,"iso_a2":"NG","iso_a3":"NGA"},"id":566,"arcs":[[2688,-2434,1200,-2289,-2688]]},{"type":"Polygon","properties":{"admin":"Nicaragua","name":"Nicaragua","postal":"NI","pop_est":5891199,"iso_a2":"NI","iso_a3":"NIC"},"id":558,"arcs":[[660,-2458,677,-2535]]},{"type":"MultiPolygon","properties":{"admin":"Netherlands","name":"Netherlands","postal":"NL","pop_est":16715999,"iso_a2":"NL","iso_a3":"NLD"},"id":528,"arcs":[[[-2287,1250]],[[2224]],[[1251,-2469,-2283,2689,-2288],[1388]]]},{"type":"MultiPolygon","properties":{"admin":"Norway","name":"Norway","postal":"N","pop_est":4676305,"iso_a2":"NO","iso_a3":"NOR"},"id":578,"arcs":[[[1810]],[[1809]],[[2014]],[[2019]],[[2002]],[[2690,-2508,2691,1143]],[[2021]],[[1629]],[[1721]],[[313]]]},{"type":"Polygon","properties":{"admin":"Nepal","name":"Nepal","postal":"NP","pop_est":28563377,"iso_a2":"NP","iso_a3":"NPL"},"id":524,"arcs":[[-2560,-2407]]},{"type":"MultiPolygon","properties":{"admin":"New Zealand","name":"New Zealand","postal":"NZ","pop_est":4213418,"iso_a2":"NZ","iso_a3":"NZL"},"id":554,"arcs":[[[126]],[[45]],[[130]],[[133]]]},{"type":"MultiPolygon","properties":{"admin":"Oman","name":"Oman","postal":"OM","pop_est":3418085,"iso_a2":"OM","iso_a3":"OMN"},"id":512,"arcs":[[[78]],[[1177,2692,2693,-2248]],[[-2250,1175]]]},{"type":"Polygon","properties":{"admin":"Pakistan","name":"Pakistan","postal":"PK","pop_est":176242949,"iso_a2":"PK","iso_a3":"PAK"},"id":586,"arcs":[[-2591,-2562,1166,2694,-2569,-2230,-2420]]},{"type":"MultiPolygon","properties":{"admin":"Panama","name":"Panama","postal":"PA","pop_est":3360474,"iso_a2":"PA","iso_a3":"PAN"},"id":591,"arcs":[[[56]],[[-2456,675,-2457,662]]]},{"type":"Polygon","properties":{"admin":"Peru","name":"Peru","postal":"PE","pop_est":29546963,"iso_a2":"PE","iso_a3":"PER"},"id":604,"arcs":[[-2332,-2321,2695,966,2696,962,2697,-2318,-2395,672,-2485,-2454]]},{"type":"MultiPolygon","properties":{"admin":"Philippines","name":"Philippines","postal":"PH","pop_est":97976603,"iso_a2":"PH","iso_a3":"PHL"},"id":608,"arcs":[[[110]],[[109]],[[112]],[[59]],[[49]],[[60]],[[61]],[[58]],[[53]],[[54]],[[79]],[[55]],[[89]],[[90]],[[93]],[[94]],[[95]],[[96]]]},{"type":"MultiPolygon","properties":{"admin":"Papua New Guinea","name":"Papua New Guinea","postal":"PG","pop_est":6057263,"iso_a2":"PG","iso_a3":"PNG"},"id":598,"arcs":[[[244]],[[202]],[[200]],[[201]],[[204]],[[166]],[[163]],[[165]],[[170]],[[2698,-2554,2699,2700,230]],[[161]],[[172]],[[174]]]},{"type":"Polygon","properties":{"admin":"Poland","name":"Poland","postal":"PL","pop_est":38482919,"iso_a2":"PL","iso_a3":"POL"},"id":616,"arcs":[[2701,-2628,-2311,2702,2703,-2460,-2465,2704,1258,2705,-2707,1261]]},{"type":"Polygon","properties":{"admin":"Puerto Rico","name":"Puerto Rico","postal":"PR","pop_est":3971020,"iso_a2":"PR","iso_a3":"PRI"},"id":630,"arcs":[[100]]},{"type":"Polygon","properties":{"admin":"North Korea","name":"Dem. Rep. Korea","postal":"KP","pop_est":22665345,"iso_a2":"KP","iso_a3":"PRK"},"id":408,"arcs":[[2707,1149,-2613,1151,-2399]]},{"type":"MultiPolygon","properties":{"admin":"Portugal","name":"Portugal","postal":"P","pop_est":10707924,"iso_a2":"PT","iso_a3":"PRT"},"id":620,"arcs":[[[1468]],[[1246,-2495]]]},{"type":"Polygon","properties":{"admin":"Paraguay","name":"Paraguay","postal":"PY","pop_est":6995655,"iso_a2":"PY","iso_a3":"PRY"},"id":600,"arcs":[[-2331,-2330,-2329,-2252,-2316]]},{"type":"MultiPolygon","properties":{"admin":"Palestine","name":"Palestine","postal":"PAL","pop_est":4119083,"iso_a2":"PS","iso_a3":"PSE"},"id":275,"arcs":[[[-2588,-2579,-2589]]]},{"type":"MultiPolygon","properties":{"admin":"French Polynesia","name":"Fr. Polynesia","postal":"PF","pop_est":287032,"iso_a2":"PF","iso_a3":"PYF"},"id":258,"arcs":[[[143]]]},{"type":"Polygon","properties":{"admin":"Qatar","name":"Qatar","postal":"QA","pop_est":833285,"iso_a2":"QA","iso_a3":"QAT"},"id":634,"arcs":[[2708,1172]]},{"type":"Polygon","properties":{"admin":"Romania","name":"Romania","postal":"RO","pop_est":22215421,"iso_a2":"RO","iso_a3":"ROU"},"id":642,"arcs":[[2709,1232,-2304,2710,-2542,2711,-2636]]},{"type":"MultiPolygon","properties":{"admin":"Russia","name":"Russia","postal":"RUS","pop_est":140041247,"iso_a2":"RU","iso_a3":"RUS"},"id":643,"arcs":[[[1498]],[[1502]],[[1479]],[[2141]],[[2198]],[[1535]],[[2712,-2629,-2702,1262,2713,1264]],[[1541]],[[1608]],[[2714,1867]],[[1808]],[[2004]],[[2001]],[[2020]],[[2022]],[[1996]],[[2715,1998,2716,2000]],[[2024]],[[2023]],[[2114]],[[2139]],[[2119]],[[1610]],[[1611]],[[1612]],[[1666]],[[1665]],[[2140]],[[2717,1148,-2708,-2398,2718,1354,2719,-2426,-2667,-2424,-2601,1273,-2276,-2517,1230,2720,-2309,-2632,-2499,2721,1399,2722,-2496,1269,-2506,-2691,1144,2723,1146]],[[272]],[[310]],[[273]],[[311]],[[316]],[[317]],[[318]],[[314]],[[319]],[[321]],[[320]],[[315]],[[322]]]},{"type":"Polygon","properties":{"admin":"Rwanda","name":"Rwanda","postal":"RW","pop_est":10473282,"iso_a2":"RW","iso_a3":"RWA"},"id":646,"arcs":[[2724,-2281,-2447,2725,1292,2726,-2444,2727]]},{"type":"Polygon","properties":{"admin":"Western Sahara","name":"W. Sahara","postal":"WS","pop_est":-99,"iso_a2":"EH","iso_a3":"ESH"},"id":732,"arcs":[[-2483,-2678,1215,2728,-2633]]},{"type":"MultiPolygon","properties":{"admin":"Saudi Arabia","name":"Saudi Arabia","postal":"SA","pop_est":28686633,"iso_a2":"SA","iso_a3":"SAU"},"id":682,"arcs":[[[-2617,1171,-2709,1173,-2249,-2694,2729,1179,-2587,-2574]]]},{"type":"Polygon","properties":{"admin":"Sudan","name":"Sudan","postal":"SD","pop_est":25946220,"iso_a2":"SD","iso_a3":"SDN"},"id":729,"arcs":[[1183,-2491,-2505,2730,-2344,2731,-2623,-2488]]},{"type":"Polygon","properties":{"admin":"South Sudan","name":"S. Sudan","postal":"SS","pop_est":10625176,"iso_a2":"SS","iso_a3":"SSD"},"id":728,"arcs":[[-2504,-2607,2732,-2437,-2345,-2731]]},{"type":"Polygon","properties":{"admin":"Senegal","name":"Senegal","postal":"SN","pop_est":13711597,"iso_a2":"SN","iso_a3":"SEN"},"id":686,"arcs":[[-2661,-2524,-2526,1211,-2525,1213,-2677]]},{"type":"Polygon","properties":{"admin":"South Georgia and South Sandwich Islands","name":"S. Geo. and S. Sandw. Is.","postal":"GS","pop_est":30,"iso_a2":"GS","iso_a3":"SGS"},"id":239,"arcs":[[26]]},{"type":"MultiPolygon","properties":{"admin":"Solomon Islands","name":"Solomon Is.","postal":"SB","pop_est":595613,"iso_a2":"SB","iso_a3":"SLB"},"id":90,"arcs":[[[241]],[[246]],[[199]],[[215]],[[205]],[[221]],[[224]],[[222]],[[152]]]},{"type":"MultiPolygon","properties":{"admin":"Sierra Leone","name":"Sierra Leone","postal":"SL","pop_est":6440053,"iso_a2":"SL","iso_a3":"SLE"},"id":694,"arcs":[[[57]],[[-2622,1208,-2522]]]},{"type":"Polygon","properties":{"admin":"El Salvador","name":"El Salvador","postal":"SV","pop_est":7185218,"iso_a2":"SV","iso_a3":"SLV"},"id":222,"arcs":[[-2536,679,-2530]]},{"type":"Polygon","properties":{"admin":"Somaliland","name":"Somaliland","postal":"SL","pop_est":3500000,"iso_a2":"-99","iso_a3":"-99"},"id":-99,"arcs":[[2733,-2501,-2472,1186]]},{"type":"Polygon","properties":{"admin":"Somalia","name":"Somalia","postal":"SO","pop_est":9832017,"iso_a2":"SO","iso_a3":"SOM"},"id":706,"arcs":[[-2602,-2502,-2734,1187]]},{"type":"Polygon","properties":{"admin":"Republic of Serbia","name":"Serbia","postal":"RS","pop_est":7379339,"iso_a2":"RS","iso_a3":"SRB"},"id":688,"arcs":[[-2711,-2303,-2659,-2616,-2664,-2305,-2538,-2543]]},{"type":"Polygon","properties":{"admin":"Sao Tome and Principe","name":"São Tomé and Principe","postal":"ST","pop_est":212679,"iso_a2":"ST","iso_a3":"STP"},"id":678,"arcs":[[250]]},{"type":"Polygon","properties":{"admin":"Suriname","name":"Suriname","postal":"SR","pop_est":481267,"iso_a2":"SR","iso_a3":"SUR"},"id":740,"arcs":[[-2509,-2323,-2532,666]]},{"type":"Polygon","properties":{"admin":"Slovakia","name":"Slovakia","postal":"SK","pop_est":5463046,"iso_a2":"SK","iso_a3":"SVK"},"id":703,"arcs":[[2734,-2545,-2262,-2461,-2704]]},{"type":"Polygon","properties":{"admin":"Slovenia","name":"Slovenia","postal":"SLO","pop_est":2005692,"iso_a2":"SI","iso_a3":"SVN"},"id":705,"arcs":[[-2539,1241,-2586,-2264,-2544]]},{"type":"MultiPolygon","properties":{"admin":"Sweden","name":"Sweden","postal":"S","pop_est":9059651,"iso_a2":"SE","iso_a3":"SWE"},"id":752,"arcs":[[[1588]],[[1576]],[[1271,2735,1142,-2692,-2507]]]},{"type":"Polygon","properties":{"admin":"Swaziland","name":"Swaziland","postal":"SW","pop_est":1123913,"iso_a2":"SZ","iso_a3":"SWZ"},"id":748,"arcs":[[-2669,2736]]},{"type":"Polygon","properties":{"admin":"Syria","name":"Syria","postal":"SYR","pop_est":20178485,"iso_a2":"SY","iso_a3":"SYR"},"id":760,"arcs":[[-2576,-2590,-2585,-2621,1227,2737]]},{"type":"Polygon","properties":{"admin":"Chad","name":"Chad","postal":"TD","pop_est":10329208,"iso_a2":"TD","iso_a3":"TCD"},"id":148,"arcs":[[-2732,-2349,-2436,-2435,-2689,-2687,-2624]]},{"type":"Polygon","properties":{"admin":"Togo","name":"Togo","postal":"TG","pop_est":6019877,"iso_a2":"TG","iso_a3":"TGO"},"id":768,"arcs":[[-2290,1202,-2518,-2294]]},{"type":"MultiPolygon","properties":{"admin":"Thailand","name":"Thailand","postal":"TH","pop_est":65905410,"iso_a2":"TH","iso_a3":"THA"},"id":764,"arcs":[[[-2619,-2610,1160,-2682,1162,-2663]]]},{"type":"Polygon","properties":{"admin":"Tajikistan","name":"Tajikistan","postal":"TJ","pop_est":7349145,"iso_a2":"TJ","iso_a3":"TJK"},"id":762,"arcs":[[-2608,-2421,-2235,2738]]},{"type":"Polygon","properties":{"admin":"Turkmenistan","name":"Turkmenistan","postal":"TM","pop_est":4884887,"iso_a2":"TM","iso_a3":"TKM"},"id":795,"arcs":[[-2233,-2567,1276,-2600,2739,2740,1382,2741,2742]]},{"type":"MultiPolygon","properties":{"admin":"East Timor","name":"Timor-Leste","postal":"TL","pop_est":1131612,"iso_a2":"TL","iso_a3":"TLS"},"id":626,"arcs":[[[2743,2744,-2548]],[[2745,-2551,2746,212]]]},{"type":"Polygon","properties":{"admin":"Trinidad and Tobago","name":"Trinidad and Tobago","postal":"TT","pop_est":1310000,"iso_a2":"TT","iso_a3":"TTO"},"id":780,"arcs":[[48]]},{"type":"MultiPolygon","properties":{"admin":"Tunisia","name":"Tunisia","postal":"TN","pop_est":10486339,"iso_a2":"TN","iso_a3":"TUN"},"id":788,"arcs":[[[-2626,-2478,1221]]]},{"type":"MultiPolygon","properties":{"admin":"Turkey","name":"Turkey","postal":"TR","pop_est":76805524,"iso_a2":"TR","iso_a3":"TUR"},"id":792,"arcs":[[[-2516,-2259,-2273,-2572,-2577,-2738,1228]],[[1234,-2528,-2300]]]},{"type":"Polygon","properties":{"admin":"Taiwan","name":"Taiwan","postal":"TW","pop_est":22974347,"iso_a2":"TW","iso_a3":"TWN"},"id":158,"arcs":[[1436]]},{"type":"MultiPolygon","properties":{"admin":"United Republic of Tanzania","name":"Tanzania","postal":"TZ","pop_est":41048532,"iso_a2":"TZ","iso_a3":"TZA"},"id":834,"arcs":[[[154]],[[168]],[[2210]],[[2211]],[[2747,1297,2748,-2603,1189,-2676,2749,1286,-2681,2750,2751,1307,2752,-2277,-2725,2753]]]},{"type":"MultiPolygon","properties":{"admin":"Uganda","name":"Uganda","postal":"UG","pop_est":32369558,"iso_a2":"UG","iso_a3":"UGA"},"id":800,"arcs":[[[2754,1295,2755,-2754,-2728,-2443,2756,1302,2757,-2440,1317,2758,-2438,-2733,-2606]]]},{"type":"Polygon","properties":{"admin":"Ukraine","name":"Ukraine","postal":"UA","pop_est":45700395,"iso_a2":"UA","iso_a3":"UKR"},"id":804,"arcs":[[1231,-2710,-2637,-2712,-2541,-2735,-2703,-2310,-2721]]},{"type":"Polygon","properties":{"admin":"Uruguay","name":"Uruguay","postal":"UY","pop_est":3494382,"iso_a2":"UY","iso_a3":"URY"},"id":858,"arcs":[[2759,953,2760,-2325,669,-2254,-2328]]},{"type":"MultiPolygon","properties":{"admin":"United States of America","name":"United States","postal":"US","pop_est":313973000,"iso_a2":"US","iso_a3":"USA"},"id":840,"arcs":[[[73,74,75,76,77]],[[62,63]],[[64,65,66]],[[68,69,70,71,72]],[[323,324]],[[1456,1457,1458]],[[2212,2213,2214]],[[2220,2221,2222,2223,2219]],[[-2374,-2373,-2372,-2371,-2370,-2369,2761,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1030,1031,1032,1033,1034,1035,1036,1037,1038,-2367,1021,1022,1023,1024,1025,1026,-2366,2762,1009,1010,1011,1012,1013,1014,2763,-2363,-2362,-2360,-2359,-2358,-2357,-2356,-2355,-2354,2764,984,2765,-2351,-2350,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,-2655,-2654,-2653,-2652,-2651,-2650,-2649,-2648,-2647,-2646,-2645,-2644,-2643,-2642,-2641,-2640,-2639,-2638,-2658,-2657,-2656,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,-2377,-2376,-2375]],[[1506]],[[1503,1504,1505]],[[1507,1508]],[[2199]],[[1509,1510,1511,1512]],[[1521,1522,1523]],[[1536,1537,1538,1539,1540]],[[1544,1545]],[[1547,1548,1549,1550,1551,1552]],[[1555,1556,1557,1558,1559,1560,1561,1562]],[[1566,1567,1568]],[[1570,1571,1572]],[[1595,1596,1597,1598,1599]],[[1589,1590,1591,1592,1593,1594]],[[1577,1578,1579,1580,1581,1582,1583,1584,1585,1586,1587]],[[1604,1605,1606]],[[1601,1602,1603]],[[1573,1574,1575]],[[1797,1798,1799]],[[1792,1793,1794,1795,1796]],[[1824,1825,1826,1827,1828,1829,1830]],[[867,868,869,870,871,872,-2388,-2387,-2386,-2385,-2384,-2383,-2382,-2381,-2380,-2379,-2378,745,746,2766,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866]]]},{"type":"MultiPolygon","properties":{"admin":"Uzbekistan","name":"Uzbekistan","postal":"UZ","pop_est":27606007,"iso_a2":"UZ","iso_a3":"UZB"},"id":860,"arcs":[[[2767,-2593,2768,1374,2769,-2596,-2609,-2739,-2234,-2743,2770,1380,2771,-2740,-2599,2772,1370]]]},{"type":"MultiPolygon","properties":{"admin":"Venezuela","name":"Venezuela","postal":"VE","pop_est":26814843,"iso_a2":"VE","iso_a3":"VEN"},"id":862,"arcs":[[[51]],[[-2533,-2334,-2453,664]]]},{"type":"MultiPolygon","properties":{"admin":"Vietnam","name":"Vietnam","postal":"VN","pop_est":86967524,"iso_a2":"VN","iso_a3":"VNM"},"id":704,"arcs":[[[50]],[[1158,-2612,-2618,-2402]]]},{"type":"MultiPolygon","properties":{"admin":"Vanuatu","name":"Vanuatu","postal":"VU","pop_est":218519,"iso_a2":"VU","iso_a3":"VUT"},"id":548,"arcs":[[[137]],[[144]],[[147]],[[145]],[[236]],[[237]]]},{"type":"MultiPolygon","properties":{"admin":"Samoa","name":"Samoa","postal":"WS","pop_est":219998,"iso_a2":"WS","iso_a3":"WSM"},"id":882,"arcs":[[[46]],[[47]]]},{"type":"MultiPolygon","properties":{"admin":"Yemen","name":"Yemen","postal":"YE","pop_est":23822783,"iso_a2":"YE","iso_a3":"YEM"},"id":887,"arcs":[[[91]],[[1178,-2730,-2693]]]},{"type":"Polygon","properties":{"admin":"South Africa","name":"South Africa","postal":"ZA","pop_est":49052489,"iso_a2":"ZA","iso_a3":"ZAF"},"id":710,"arcs":[[-2670,-2737,-2668,1191,-2686,-2342,2773],[-2627]]},{"type":"Polygon","properties":{"admin":"Zambia","name":"Zambia","postal":"ZM","pop_est":11862740,"iso_a2":"ZM","iso_a3":"ZMB"},"id":894,"arcs":[[2774,-2751,-2680,-2672,2775,2776,1279,2777,-2685,-2236,-2450,2778,1313]]},{"type":"Polygon","properties":{"admin":"Zimbabwe","name":"Zimbabwe","postal":"ZW","pop_est":12619600,"iso_a2":"ZW","iso_a3":"ZWE"},"id":716,"arcs":[[-2776,-2671,-2774,-2341,-2778,1280,2779,1282]]}]},"states":{"type":"GeometryCollection","geometries":[{"type":"Polygon","properties":{"iso_a2":"CA","name":"Alberta","postal":"AB","admin":"Canada"},"arcs":[[2780,2781,2782,2783]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"British Columbia","postal":"BC","admin":"Canada"},"arcs":[[[2784,2785,2786,2787,2788,2789,2790,2791,2792,2793,2794,2795,2796,2797,2798]],[[2799,2800,2801,2802]],[[2803,2804,2805,2806]],[[2807,2808,2809]],[[2810,2811,2812,2813,2814,2815,2816]],[[2817,2818,2819,2820,2821,2822,2823,2824,2825,2826,2827,-2782,2828,2829,2830,2831,2832,2833,2834,2835,2836,2837,2838,2839,2840,2841,2842,2843,2844,2845,2846,2847,2848,2849,736,2850,2851,2852,2853,2854,2855,2856,2857]]]},{"type":"Polygon","properties":{"iso_a2":"CA","name":"Manitoba","postal":"MB","admin":"Canada"},"arcs":[[2858,2859,2860,2861,2862,2863,2864,2865,2866,2867,2868,2869,2870,2871,2872,2873,2874]]},{"type":"Polygon","properties":{"iso_a2":"CA","name":"New Brunswick","postal":"NB","admin":"Canada"},"arcs":[[2875,2876,2877,2878,2879,2880,2881,2882,2883,2884,2885,2886,2887,2888,2889,2890,2891]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"Newfoundland and Labrador","postal":"NL","admin":"Canada"},"arcs":[[[2892,2893,2894,2895,2896,2897,2898,2899,2900,2901,2902,2903,2904,2905,2906,2907,2908,2909,2910,2911,2912,2913,2914,2915,2916,2917,2918,2919,2920,2921,2922,2923,2924,2925,2926,2927,2928,2929,2930,2931]],[[2932,2933,2934,2935,2936,2937,2938,2939,2940,2941,2942,2943,2944,2945,2946,2947,2948,2949,2950,2951,2952,2953,2954,2955,2956,2957,2958,2959,2960,2961,2962,2963,2964,2965,2966,2967,2968,2969,2970,2971,2972]]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"Nova Scotia","postal":"NS","admin":"Canada"},"arcs":[[[-2876,2973,2974,2975,2976,2977,2978,2979,2980,2981,2982,2983,2984,2985,2986,2987,2988,2989,2990,2991]],[[2992,2993,2994,2995,2996,2997,2998,2999]]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"Northwest Territories","postal":"NT","admin":"Canada"},"arcs":[[[3000,3001,-2783,-2828,3002,3003,3004,3005,3006,3007,3008,3009,3010,3011,3012,3013,3014,3015,3016,3017,3018,3019,3020,3021,3022,3023,3024,3025,3026]],[[3027,3028,3029,3030,3031,3032,3033,3034,3035,3036,3037,3038,3039,3040,3041,3042,3043,3044,3045,3046,3047,3048,3049,3050,3051]],[[3052,3053,3054,3055,3056,3057,3058,3059,3060,3061,3062,3063,3064,3065,3066,3067,3068]],[[3069]],[[3070,3071,3072,3073,3074,3075,3076,3077,3078,3079,3080,3081,3082,3083,3084,3085,3086,3087,3088]],[[3089,3090,3091,3092,3093,3094,3095,3096,3097,3098,3099]],[[3100,3101,3102]],[[3103,3104,3105]],[[3106]]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"Nunavut","postal":"NU","admin":"Canada"},"arcs":[[[3107,3108,3109,3110]],[[3111,3112,3113]],[[3114,3115]],[[3116,3117]],[[3118,3119,3120,3121]],[[3122,3123,3124,3125]],[[3126,3127,3128,3129,3130,3131]],[[3132,3133,3134]],[[3135]],[[3136,3137,3138,3139,3140,3141,3142,3143,3144,3145,3146,3147,3148,3149,3150,3151,3152]],[[3153,3154,3155]],[[3156,3157]],[[3158,3159]],[[3160,3161,3162]],[[3163,3164,3165,3166]],[[3167,3168]],[[3169,3170,3171,3172]],[[3173,3174]],[[3175,3176]],[[3177,3178,3179,3180,3181,3182,3183]],[[-2863,-3001,3184,3185,3186,3187,3188,3189,3190,3191,3192,3193,3194,3195,3196,3197,3198,3199,3200,3201,3202,3203,3204,3205,3206,3207,3208,3209,3210,3211,3212,3213,3214,3215,3216,3217,3218,3219,3220,3221,3222,936,3223,3224,3225,3226,3227,3228,3229,3230,3231,3232,3233,3234,3235,3236,3237,3238,3239,3240,3241,3242,3243,3244,3245,3246,3247,3248,3249,3250,3251,3252,3253,3254,3255,3256,3257,3258,3259,3260,3261,3262,3263,3264,3265,3266,3267,3268,3269,3270,3271,3272,3273,3274,3275,3276,3277,3278,3279,3280]],[[-3028,3281,3282,3283,3284,3285,3286,3287,3288,3289,3290,3291,3292,3293,3294,3295,3296,3297,3298,3299,3300,3301,3302,3303,3304,3305]],[[3306,3307,3308,3309]],[[3310,3311,3312,3313,3314,3315,3316,3317]],[[3318,3319,3320,3321,3322,3323,3324,3325,3326,3327,3328,3329,3330,3331,3332,3333,3334,3335,3336,3337,3338,3339,3340,3341,3342,3343,3344,3345,3346,3347,3348,3349,3350,3351,3352,3353,3354,3355,3356,3357,3358,3359,3360,3361,3362,3363,3364,3365,3366,3367,3368,3369,3370,3371,3372,3373,3374,3375,3376,3377,3378,3379,3380,3381,3382,3383,3384,3385,3386,3387,3388,3389,3390,3391,3392,3393,3394,3395,3396,3397,3398,3399,3400,3401,3402,3403,3404,3405,3406,3407,3408,3409,3410,3411,3412,3413,3414,3415,3416,3417,3418,3419,3420,3421,3422,3423,3424,3425,3426,3427,3428,3429,3430,3431,3432,3433,3434,3435]],[[2091,3436,3437,3438,3439,3440,3441,3442,3443,3444,3445,3446,3447,3448,3449,3450,3451,3452,3453,3454,3455,3456]],[[3457]],[[3458,3459,3460,3461,3462,3463,3464,3465,3466,3467,3468]],[[3469,3470,3471,3472]],[[3473,3474,3475,3476,3477]],[[3478,3479,3480]],[[3481,3482,3483]],[[3484,3485,3486,3487,3488,3489,3490,3491,3492,3493,3494,3495,3496]],[[-3071,3497,3498,3499,3500,3501,3502,3503,3504,3505,3506,3507,3508]],[[3509,3510,3511,3512,3513,3514,3515,3516,3517,3518,3519,3520,3521,3522,3523,3524,3525,3526,3527,3528,3529,3530]],[[3531,3532,3533]],[[3534]],[[3535,3536,3537]],[[3538,3539,3540,3541]],[[3542,3543,3544,3545,3546,3547,3548,3549,3550,3551]],[[3552,3553]],[[3554,3555,3556,3557,3558,3559,3560,3561,3562,3563,3564,3565,3566,3567,3568,3569,3570,3571,3572,3573,3574,3575,3576]],[[3577,3578,3579,3580,3581,3582,3583,1729,3584,3585,3586,3587,3588,3589,3590,3591,3592,3593,3594,3595,3596,3597,3598,3599,3600,3601,3602,3603,3604,3605,3606,3607,3608,3609,3610,3611,3612,3613,3614,3615,3616,3617,3618,3619,3620,3621,3622,3623,3624,3625,3626,3627,3628,3629,3630,3631,3632,3633,3634,3635,3636,3637,3638,3639,3640,3641,3642,3643,3644,3645]]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"Ontario","postal":"ON","admin":"Canada"},"arcs":[[[3646,3647,3648,3649]],[[3650,3651,3652]],[[3653,3654,3655,3656,3657,3658,3659,3660,3661,3662,3663,3664,3665,3666,3667,3668,3669,3670,3671,3672,3673,3674,3675,3676,3677,3678,3679,3680,3681,3682,3683,3684,3685,3686,3687,3688,3689,3690,3691,3692,3693,3694,3695,3696,3697,3698,3699,3700,3701,3702,-2859,3703,3704,3705,3706,3707,3708,3709,3710,3711,3712,3713,3714,3715]]]},{"type":"Polygon","properties":{"iso_a2":"CA","name":"Prince Edward Island","postal":"PE","admin":"Canada"},"arcs":[[3716,3717,3718,3719,3720,3721,3722,3723,3724,3725]]},{"type":"MultiPolygon","properties":{"iso_a2":"CA","name":"Québec","postal":"QC","admin":"Canada"},"arcs":[[[3726,3727]],[[-2884,3728,3729,3730,3731,3732,3733,3734,3735,3736,3737,3738,3739,3740,3741,3742,3743,3744,3745,3746,3747]],[[1473,3748,3749,3750,3751,3752]],[[-2933,3753,3754,3755,3756,482,3757,3758,3759,3760,3761,3762,3763,3764,3765,3766,3767,3768,3769,3770,3771,3772,3773,-3654,3774,-3653,3775,3776,3777,3778,3779,3780,3781,3782,3783,3784,3785,3786,3787,3788,3789,3790,3791,3792,3793,3794,3795,3796,3797,3798,3799,3800,3801,3802,3803,3804,3805,430,3806,3807,3808,3809,3810,3811,3812,3813,3814]]]},{"type":"Polygon","properties":{"iso_a2":"CA","name":"Saskatchewan","postal":"SK","admin":"Canada"},"arcs":[[3815,3816,-2784,-3002,-2862]]},{"type":"Polygon","properties":{"iso_a2":"CA","name":"Yukon","postal":"YT","admin":"Canada"},"arcs":[[-3003,-2827,3817,3818,3819,3820,3821]]},{"type":"MultiPolygon","properties":{"iso_a2":"US","name":"Alaska","postal":"AK","admin":"United States of America"},"arcs":[[[3822]],[[3823,3824,3825]],[[3826,3827]],[[3828,3829,3830,3831]],[[3832,3833,3834]],[[3835,3836,3837,3838,3839]],[[3840,3841]],[[3842,3843,3844,3845,1551,3846]],[[3847,3848,3849,3850,3851,3852,3853,3854]],[[3855,3856,3857]],[[3858,3859,3860]],[[3861,3862,3863,3864,3865]],[[3866,3867,3868,3869,3870,3871]],[[3872,3873,3874,3875,3876,3877,3878,3879,3880,3881,3882]],[[3883,3884,3885]],[[3886,3887,3888]],[[3889,3890,3891]],[[3892,3893,3894]],[[3895,3896,3897,3898,3899]],[[3900,3901,3902,3903,3904,3905,3906]],[[-3820,-3819,-3818,-2826,-2825,-2824,-2823,-2822,-2821,-2820,-2819,-2818,3907,3908,3909,3910,3911,3912,3913,3914,755,3915,3916,3917,3918,3919,3920,3921,3922,3923,3924,3925,3926,3927,3928,3929,3930,3931,3932,3933,3934,3935,3936,3937,3938,3939,3940,3941,3942,3943,3944,3945,3946,3947,3948,3949,3950,3951,3952,3953,3954,3955,3956,3957,3958,3959,3960,3961,3962,3963,3964,3965,3966,3967,3968,3969,3970,3971,3972,3973,3974,3975,3976,3977,3978,3979,3980,3981,3982,3983,3984,3985,3986,3987,3988,3989,3990,3991,3992,3993,3994,3995,3996,3997,3998,3999,4000,4001,4002,4003,4004,4005,4006,4007,4008,4009,4010,4011,4012,4013,4014,4015,4016,4017,4018,4019,4020,4021,4022,4023,4024,4025,4026,4027,4028,4029,4030,4031]]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Alabama","postal":"AL","admin":"United States of America"},"arcs":[[4032,4033,4034,4035,4036,4037,4038,4039]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Arkansas","postal":"AR","admin":"United States of America"},"arcs":[[4040,4041,4042,4043,4044,4045]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Arizona","postal":"AZ","admin":"United States of America"},"arcs":[[4046,4047,4048,4049,4050,4051,4052,4053]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"California","postal":"CA","admin":"United States of America"},"arcs":[[4054,4055,-4052,4056,4057,4058,4059,4060,4061,4062,4063,4064,4065,4066,4067,4068,4069,4070,4071,4072,4073]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Colorado","postal":"CO","admin":"United States of America"},"arcs":[[4074,4075,4076,4077,4078,4079]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Connecticut","postal":"CT","admin":"United States of America"},"arcs":[[4080,4081,4082,4083,4084]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Delaware","postal":"DE","admin":"United States of America"},"arcs":[[4085,4086,4087,4088,4089]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Florida","postal":"FL","admin":"United States of America"},"arcs":[[4090,4091,4092,4093,4094,4095,4096,4097,4098,4099,4100,4101,4102,4103,4104,4105,4106,4107,4108,4109,4110,622,4111,4112,4113,4114,4115,4116,-4040,4117]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Georgia","postal":"GA","admin":"United States of America"},"arcs":[[4118,4119,4120,-4118,-4039,4121,4122,4123]]},{"type":"MultiPolygon","properties":{"iso_a2":"US","name":"Hawaii","postal":"HI","admin":"United States of America"},"arcs":[[[4124,4125,4126,4127,4128]],[[4129,4130]],[[4131,4132,4133]],[[4134,4135,4136,4137,4138]],[[4139,4140]]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Iowa","postal":"IA","admin":"United States of America"},"arcs":[[4141,4142,4143,4144,4145,4146]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Idaho","postal":"ID","admin":"United States of America"},"arcs":[[4147,4148,4149,4150,4151,-2830,4152]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Illinois","postal":"IL","admin":"United States of America"},"arcs":[[4153,4154,4155,4156,4157,-4142,4158]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Indiana","postal":"IN","admin":"United States of America"},"arcs":[[4159,4160,-4156,4161,4162]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Kansas","postal":"KS","admin":"United States of America"},"arcs":[[4163,-4075,4164,4165]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Kentucky","postal":"KY","admin":"United States of America"},"arcs":[[4166,4167,4168,4169,-4157,-4161,4170]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Louisiana","postal":"LA","admin":"United States of America"},"arcs":[[4171,4172,4173,4174,4175,4176,4177,4178,4179,4180,4181,4182,4183,4184,-4043,4185]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Massachusetts","postal":"MA","admin":"United States of America"},"arcs":[[4186,-4085,4187,4188,4189,4190,4191,4192,4193,4194,4195,4196,4197]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Maryland","postal":"MD","admin":"United States of America"},"arcs":[[-4089,4198,4199,4200,4201,4202,4203,4204,4205,4206,4207,4208,4209,4210,4211]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Maine","postal":"ME","admin":"United States of America"},"arcs":[[-2881,-2880,4212,4213,4214,4215,4216,4217,4218,4219,4220,-3732,-3731,-3730,-3729,-2883,-2882]]},{"type":"MultiPolygon","properties":{"iso_a2":"US","name":"Michigan","postal":"MI","admin":"United States of America"},"arcs":[[[-3672,4221,4222,4223,-4163,4224,4225,4226,4227,4228,4229,4230,4231,4232,4233,4234,4235,4236,4237,4238,4239,4240,4241,4242]],[[4243,4244,4245,4246,4247,4248,4249,4250,4251,4252,4253,4254,4255,4256]],[[4257,4258,4259,4260,4261]]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Minnesota","postal":"MN","admin":"United States of America"},"arcs":[[4262,4263,4264,-4146,4265,4266,-2860,-3703,-3702,-3701,-3700,-3699,-3698]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Missouri","postal":"MO","admin":"United States of America"},"arcs":[[-4158,-4170,4267,-4046,4268,-4166,4269,-4143]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Mississippi","postal":"MS","admin":"United States of America"},"arcs":[[4270,4271,-4186,-4042,4272,-4037]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Montana","postal":"MT","admin":"United States of America"},"arcs":[[4273,4274,-4153,-2829,-2781,-3817,4275]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"North Carolina","postal":"NC","admin":"United States of America"},"arcs":[[4276,-4123,4277,4278,4279,4280,4281,4282,4283,4284,4285,4286,4287,4288,4289,4290]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"North Dakota","postal":"ND","admin":"United States of America"},"arcs":[[4291,-4276,-3816,-2861,-4267]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Nebraska","postal":"NE","admin":"United States of America"},"arcs":[[-4144,-4270,-4165,-4080,4292,4293]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"New Hampshire","postal":"NH","admin":"United States of America"},"arcs":[[4294,-4190,4295,-3734,-3733,-4221]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"New Jersey","postal":"NJ","admin":"United States of America"},"arcs":[[4296,4297,4298,4299,4300,4301]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"New Mexico","postal":"NM","admin":"United States of America"},"arcs":[[4302,4303,4304,4305,-4047,-4077,4306]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Nevada","postal":"NV","admin":"United States of America"},"arcs":[[-4053,-4056,4307,-4150,4308]]},{"type":"MultiPolygon","properties":{"iso_a2":"US","name":"New York","postal":"NY","admin":"United States of America"},"arcs":[[[4309,4310,4311]],[[4312,-4188,-4084,4313,-4298,4314,4315,4316,-3666,4317,4318,4319,4320,4321,4322,-3657,-3656,-3736]]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Ohio","postal":"OH","admin":"United States of America"},"arcs":[[4323,-4171,-4160,-4224,4324,4325,4326,4327,4328]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Oklahoma","postal":"OK","admin":"United States of America"},"arcs":[[-4045,4329,-4307,-4076,-4164,-4269]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Oregon","postal":"OR","admin":"United States of America"},"arcs":[[4330,-4151,-4308,-4055,4331,4332,4333,4334,4335,4336]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Pennsylvania","postal":"PA","admin":"United States of America"},"arcs":[[-4297,4337,-4090,-4212,4338,-4329,4339,-4315]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Rhode Island","postal":"RI","admin":"United States of America"},"arcs":[[4340,4341,-4081,-4187]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"South Carolina","postal":"SC","admin":"United States of America"},"arcs":[[4342,4343,4344,4345,4346,-4124,-4277]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"South Dakota","postal":"SD","admin":"United States of America"},"arcs":[[-4266,-4145,-4294,4347,-4274,-4292]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Tennessee","postal":"TN","admin":"United States of America"},"arcs":[[4348,-4278,-4122,-4038,-4273,-4041,-4268,-4169]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Texas","postal":"TX","admin":"United States of America"},"arcs":[[-4044,-4185,4349,4350,4351,4352,4353,4354,4355,4356,4357,4358,4359,4360,4361,4362,4363,4364,4365,4366,4367,4368,4369,4370,-4303,-4330]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Utah","postal":"UT","admin":"United States of America"},"arcs":[[4371,-4078,-4054,-4309,-4149]]},{"type":"MultiPolygon","properties":{"iso_a2":"US","name":"Virginia","postal":"VA","admin":"United States of America"},"arcs":[[[-4200,4372,4373,4374]],[[-4210,4375,4376,4377,4378,4379,4380,4381,4382,-4279,-4349,-4168,4383]]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Vermont","postal":"VT","admin":"United States of America"},"arcs":[[-4189,-4313,-3735,-4296]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Washington","postal":"WA","admin":"United States of America"},"arcs":[[-4331,4384,4385,4386,4387,4388,4389,4390,4391,4392,4393,4394,4395,4396,-2832,-2831,-4152]]},{"type":"MultiPolygon","properties":{"iso_a2":"US","name":"Wisconsin","postal":"WI","admin":"United States of America"},"arcs":[[[4397,4398,4399]],[[-4244,4400,4401,4402,4403,-4159,-4147,-4265,4404,4405,4406,4407]]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"West Virginia","postal":"WV","admin":"United States of America"},"arcs":[[-4211,-4384,-4167,-4324,-4339]]},{"type":"Polygon","properties":{"iso_a2":"US","name":"Wyoming","postal":"WY","admin":"United States of America"},"arcs":[[-4293,-4079,-4372,-4148,-4275,-4348]]}]},"cities":{"type":"GeometryCollection","geometries":[{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"San Bernardino","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[174161,706874]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Bridgeport","adm0name":"United States of America","adm1name":"Connecticut","iso_a2":"US"},"coordinates":[296661,748698]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Rochester","adm0name":"United States of America","adm1name":"New York","iso_a2":"US"},"coordinates":[284383,760490]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Manchester","adm0name":"United Kingdom","adm1name":"Manchester","iso_a2":"GB"},"coordinates":[493750,821690]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Gujranwala","adm0name":"Pakistan","adm1name":"Punjab","iso_a2":"PK"},"coordinates":[706063,695262]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Incheon","adm0name":"South Korea","adm1name":"Inch'on-gwangyoksi","iso_a2":"KR"},"coordinates":[851778,726754]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Benin City","adm0name":"Nigeria","adm1name":"Edo","iso_a2":"NG"},"coordinates":[515605,542293]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Xiamen","adm0name":"China","adm1name":"Fujian","iso_a2":"CN"},"coordinates":[827994,649581]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Nanchong","adm0name":"China","adm1name":"Sichuan","iso_a2":"CN"},"coordinates":[794799,687086]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Neijiang","adm0name":"China","adm1name":"Sichuan","iso_a2":"CN"},"coordinates":[791800,679976]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Nanyang","adm0name":"China","adm1name":"Henan","iso_a2":"CN"},"coordinates":[812577,700238]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jinxi","adm0name":"China","adm1name":"Liaoning","iso_a2":"CN"},"coordinates":[835632,746152]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Yantai","adm0name":"China","adm1name":"Shandong","iso_a2":"CN"},"coordinates":[837216,727075]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Zaozhuang","adm0name":"China","adm1name":"Shandong","iso_a2":"CN"},"coordinates":[826578,711374]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Suzhou","adm0name":"China","adm1name":"Jiangsu","iso_a2":"CN"},"coordinates":[835050,690167]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Xuzhou","adm0name":"China","adm1name":"Jiangsu","iso_a2":"CN"},"coordinates":[825494,707819]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Wuxi","adm0name":"China","adm1name":"Jiangsu","iso_a2":"CN"},"coordinates":[834161,691823]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jilin","adm0name":"China","adm1name":"Jilin","iso_a2":"CN"},"coordinates":[851522,764516]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Chandigarh","adm0name":"India","adm1name":"Chandigarh","iso_a2":"IN"},"coordinates":[713272,686728]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jammu","adm0name":"India","adm1name":"Jammu and Kashmir","iso_a2":"IN"},"coordinates":[707901,698528]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Sholapur","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[710827,609416]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Aurangabad","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[709217,622600]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Nasik","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[704938,623220]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Dispur","adm0name":"India","adm1name":"Assam","iso_a2":"IN"},"coordinates":[754907,659606]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jullundur","adm0name":"India","adm1name":"Punjab","iso_a2":"IN"},"coordinates":[709907,690371]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Allahabad","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[727327,655536]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Moradabad","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[718763,675601]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ghaziabad","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[715017,674526]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Agra","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[716703,665699]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Aligarh","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[716832,669974]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Meerut","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[715827,676540]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Dhanbad","adm0name":"India","adm1name":"Jharkhand","iso_a2":"IN"},"coordinates":[740049,645733]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Gwalior","adm0name":"India","adm1name":"Madhya Pradesh","iso_a2":"IN"},"coordinates":[717160,660127]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Vadodara","adm0name":"India","adm1name":"Dadra and Nagar Haveli","iso_a2":"IN"},"coordinates":[703271,636904]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Rajkot","adm0name":"India","adm1name":"Dadra and Nagar Haveli","iso_a2":"IN"},"coordinates":[696661,636904]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Durazno","adm0name":"Uruguay","adm1name":"Durazno","iso_a2":"UY"},"coordinates":[343027,306781]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"International Falls","adm0name":"United States of America","adm1name":"Minnesota","iso_a2":"US"},"coordinates":[240525,792652]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"St. Paul","adm0name":"United States of America","adm1name":"Minnesota","iso_a2":"US"},"coordinates":[241431,770986]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Billings","adm0name":"United States of America","adm1name":"Montana","iso_a2":"US"},"coordinates":[198500,775987]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Great Falls","adm0name":"United States of America","adm1name":"Montana","iso_a2":"US"},"coordinates":[190833,786130]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Missoula","adm0name":"United States of America","adm1name":"Montana","iso_a2":"US"},"coordinates":[183352,782409]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Minot","adm0name":"United States of America","adm1name":"North Dakota","iso_a2":"US"},"coordinates":[218622,790468]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Fargo","adm0name":"United States of America","adm1name":"North Dakota","iso_a2":"US"},"coordinates":[231140,782439]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Hilo","adm0name":"United States of America","adm1name":"Hawaii","iso_a2":"US"},"coordinates":[69194,621429]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Olympia","adm0name":"United States of America","adm1name":"Washington","iso_a2":"US"},"coordinates":[158613,783392]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Spokane","adm0name":"United States of America","adm1name":"Washington","iso_a2":"US"},"coordinates":[173833,787136]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Vancouver","adm0name":"United States of America","adm1name":"Washington","iso_a2":"US"},"coordinates":[159333,775052]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Flagstaff","adm0name":"United States of America","adm1name":"Arizona","iso_a2":"US"},"coordinates":[189860,713247]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tucson","adm0name":"United States of America","adm1name":"Arizona","iso_a2":"US"},"coordinates":[191967,695526]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Santa Barbara","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[167444,708726]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Fresno","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[167297,722427]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Eureka","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[155146,746448]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Colorado Springs","adm0name":"United States of America","adm1name":"Colorado","iso_a2":"US"},"coordinates":[208911,734959]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Reno","adm0name":"United States of America","adm1name":"Nevada","iso_a2":"US"},"coordinates":[167166,738910]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Elko","adm0name":"United States of America","adm1name":"Nevada","iso_a2":"US"},"coordinates":[178438,746627]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Albuquerque","adm0name":"United States of America","adm1name":"New Mexico","iso_a2":"US"},"coordinates":[203773,712695]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Salem","adm0name":"United States of America","adm1name":"Oregon","iso_a2":"US"},"coordinates":[158267,770891]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Casper","adm0name":"United States of America","adm1name":"Wyoming","iso_a2":"US"},"coordinates":[204687,758678]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Topeka","adm0name":"United States of America","adm1name":"Kansas","iso_a2":"US"},"coordinates":[234250,736067]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Kansas City","adm0name":"United States of America","adm1name":"Missouri","iso_a2":"US"},"coordinates":[237205,736416]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tulsa","adm0name":"United States of America","adm1name":"Oklahoma","iso_a2":"US"},"coordinates":[233527,718708]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Sioux Falls","adm0name":"United States of America","adm1name":"South Dakota","iso_a2":"US"},"coordinates":[231305,762727]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Shreveport","adm0name":"United States of America","adm1name":"Louisiana","iso_a2":"US"},"coordinates":[239528,697262]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Baton Rouge","adm0name":"United States of America","adm1name":"Louisiana","iso_a2":"US"},"coordinates":[246833,685164]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ft. Worth","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[229610,698684]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Corpus Christi","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[229439,669078]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Austin","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[228487,684044]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Amarillo","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[217139,713436]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"El Paso","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[204133,693008]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Laredo","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[223591,667676]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Merida","adm0name":"Venezuela","adm1name":"Mérida","iso_a2":"VE"},"coordinates":[302417,554483]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Burlington","adm0name":"United States of America","adm1name":"Vermont","iso_a2":"US"},"coordinates":[296631,768212]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Montgomery","adm0name":"United States of America","adm1name":"Alabama","iso_a2":"US"},"coordinates":[260335,696442]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tallahassee","adm0name":"United States of America","adm1name":"Florida","iso_a2":"US"},"coordinates":[265888,685117]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Orlando","adm0name":"United States of America","adm1name":"Florida","iso_a2":"US"},"coordinates":[273939,673635]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jacksonville","adm0name":"United States of America","adm1name":"Florida","iso_a2":"US"},"coordinates":[273133,684418]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Savannah","adm0name":"United States of America","adm1name":"Georgia","iso_a2":"US"},"coordinates":[274695,694425]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Columbia","adm0name":"United States of America","adm1name":"South Carolina","iso_a2":"US"},"coordinates":[275277,706385]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Indianapolis","adm0name":"United States of America","adm1name":"Indiana","iso_a2":"US"},"coordinates":[260633,740225]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Wilmington","adm0name":"United States of America","adm1name":"North Carolina","iso_a2":"US"},"coordinates":[283486,707484]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Knoxville","adm0name":"United States of America","adm1name":"Tennessee","iso_a2":"US"},"coordinates":[266888,717820]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Richmond","adm0name":"United States of America","adm1name":"Virginia","iso_a2":"US"},"coordinates":[284855,727192]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Charleston","adm0name":"United States of America","adm1name":"West Virginia","iso_a2":"US"},"coordinates":[273243,731919]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Baltimore","adm0name":"United States of America","adm1name":"Maryland","iso_a2":"US"},"coordinates":[287161,737560]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Syracuse","adm0name":"United States of America","adm1name":"New York","iso_a2":"US"},"coordinates":[288472,759765]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Puerto Ayacucho","adm0name":"Venezuela","adm1name":"Amazonas","iso_a2":"VE"},"coordinates":[312156,538272]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Port-of-Spain","adm0name":"Trinidad and Tobago","adm1name":"Port of Spain","iso_a2":"TT"},"coordinates":[329119,567824]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Augusta","adm0name":"United States of America","adm1name":"Maine","iso_a2":"US"},"coordinates":[306167,767233]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Sault Ste. Marie","adm0name":"United States of America","adm1name":"Michigan","iso_a2":"US"},"coordinates":[265708,780176]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Atakpame","adm0name":"Togo","adm1name":"Plateaux","iso_a2":"TG"},"coordinates":[503110,549328]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Sousse","adm0name":"Tunisia","adm1name":"Sousse","iso_a2":"TN"},"coordinates":[529513,716990]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Taizz","adm0name":"Yemen","adm1name":"Ta`izz","iso_a2":"YE"},"coordinates":[622326,585327]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Sitka","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[124090,842768]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Lvov","adm0name":"Ukraine","adm1name":"L'viv","iso_a2":"UA"},"coordinates":[566750,799962]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Odessa","adm0name":"Ukraine","adm1name":"Odessa","iso_a2":"UA"},"coordinates":[585299,780156]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Zhytomyr","adm0name":"Ukraine","adm1name":"Zhytomyr","iso_a2":"UA"},"coordinates":[579616,802395]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Dnipropetrovsk","adm0name":"Ukraine","adm1name":"Dnipropetrovs'k","iso_a2":"UA"},"coordinates":[597216,791946]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Donetsk","adm0name":"Ukraine","adm1name":"Donets'k","iso_a2":"UA"},"coordinates":[605077,789102]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Kharkiv","adm0name":"Ukraine","adm1name":"Kharkiv","iso_a2":"UA"},"coordinates":[600689,800951]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Turkmenbasy","adm0name":"Turkmenistan","adm1name":"Balkan","iso_a2":"TM"},"coordinates":[647137,741831]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Bukhara","adm0name":"Uzbekistan","adm1name":"Bukhoro","iso_a2":"UZ"},"coordinates":[678972,740392]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Nukus","adm0name":"Uzbekistan","adm1name":"Karakalpakstan","iso_a2":"UZ"},"coordinates":[665596,756329]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Turkmenabat","adm0name":"Turkmenistan","adm1name":"Chardzhou","iso_a2":"TM"},"coordinates":[676610,736423]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Mary","adm0name":"Turkmenistan","adm1name":"Mary","iso_a2":"TM"},"coordinates":[671759,727476]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Andijon","adm0name":"Uzbekistan","adm1name":"Andijon","iso_a2":"UZ"},"coordinates":[700944,746376]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Haiphong","adm0name":"Vietnam","adm1name":"Qu?ng Ninh","iso_a2":"VN"},"coordinates":[796328,628135]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Da Nang","adm0name":"Vietnam","adm1name":"Ðà N?ng","iso_a2":"VN"},"coordinates":[800693,599864]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Kabwe","adm0name":"Zambia","adm1name":"Central","iso_a2":"ZM"},"coordinates":[579027,419168]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Mufulira","adm0name":"Zambia","adm1name":"Copperbelt","iso_a2":"ZM"},"coordinates":[578499,430365]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Kitwe","adm0name":"Zambia","adm1name":"Copperbelt","iso_a2":"ZM"},"coordinates":[578388,428825]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Livingstone","adm0name":"Zambia","adm1name":"Southern","iso_a2":"ZM"},"coordinates":[571833,398907]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Chitungwiza","adm0name":"Zimbabwe","adm1name":"Harare","iso_a2":"ZW"},"coordinates":[586388,398077]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Douala","adm0name":"Cameroon","adm1name":"Littoral","iso_a2":"CM"},"coordinates":[526967,528784]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Birmingham","adm0name":"United Kingdom","adm1name":"West Midlands","iso_a2":"GB"},"coordinates":[494661,815614]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Belfast","adm0name":"United Kingdom","adm1name":"Belfast","iso_a2":"GB"},"coordinates":[483444,828192]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Izmir","adm0name":"Turkey","adm1name":"Izmir","iso_a2":"TR"},"coordinates":[575416,732442]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Bursa","adm0name":"Turkey","adm1name":"Bursa","iso_a2":"TR"},"coordinates":[580744,742892]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Samsun","adm0name":"Turkey","adm1name":"Samsun","iso_a2":"TR"},"coordinates":[600954,749278]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Konya","adm0name":"Turkey","adm1name":"Konya","iso_a2":"TR"},"coordinates":[590203,729117]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Adana","adm0name":"Turkey","adm1name":"Adana","iso_a2":"TR"},"coordinates":[598105,723904]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Gulu","adm0name":"Uganda","adm1name":"Aswa","iso_a2":"UG"},"coordinates":[589666,521187]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Kigali","adm0name":"Rwanda","adm1name":"Kigali City","iso_a2":"RW"},"coordinates":[583496,493155]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Cottica","adm0name":"Suriname","adm1name":"Sipaliwini","iso_a2":"SR"},"coordinates":[349352,527527]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Cordoba","adm0name":"Spain","adm1name":"Andalucía","iso_a2":"ES"},"coordinates":[486749,729135]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Maradi","adm0name":"Niger","adm1name":"Maradi","iso_a2":"NE"},"coordinates":[519712,584648]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Tahoua","adm0name":"Niger","adm1name":"Tahoua","iso_a2":"NE"},"coordinates":[514610,592991]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Constanta","adm0name":"Romania","adm1name":"Constanta","iso_a2":"RO"},"coordinates":[579472,766594]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Luleå","adm0name":"Sweden","adm1name":"Norrbotten","iso_a2":"SE"},"coordinates":[561551,893341]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Sundsvall","adm0name":"Sweden","adm1name":"Västernorrland","iso_a2":"SE"},"coordinates":[548101,874403]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Iasi","adm0name":"Romania","adm1name":"Iasi","iso_a2":"RO"},"coordinates":[576596,784163]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Surat Thani","adm0name":"Thailand","adm1name":"Surat Thani","iso_a2":"TH"},"coordinates":[775944,558926]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Chiang Mai","adm0name":"Thailand","adm1name":"Chiang Mai","iso_a2":"TH"},"coordinates":[774944,616096]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Nakhon Ratchasima","adm0name":"Thailand","adm1name":"Nakhon Ratchasima","iso_a2":"TH"},"coordinates":[783611,593584]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Mbabane","adm0name":"Swaziland","adm1name":"Hhohho","iso_a2":"SZ"},"coordinates":[586481,348805]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Piura","adm0name":"Peru","adm1name":"Piura","iso_a2":"PE"},"coordinates":[276028,473851]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Arequipa","adm0name":"Peru","adm1name":"Arequipa","iso_a2":"PE"},"coordinates":[301300,407449]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Chimbote","adm0name":"Peru","adm1name":"Ancash","iso_a2":"PE"},"coordinates":[281750,450982]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Pucallpa","adm0name":"Peru","adm1name":"Ucayali","iso_a2":"PE"},"coordinates":[292958,455136]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Iquitos","adm0name":"Peru","adm1name":"Loreto","iso_a2":"PE"},"coordinates":[296527,482500]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Huancayo","adm0name":"Peru","adm1name":"Junín","iso_a2":"PE"},"coordinates":[291110,433149]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Ciudad del Este","adm0name":"Paraguay","adm1name":"Alto Paraná","iso_a2":"PY"},"coordinates":[348289,353544]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Ponta Delgada","adm0name":"Portugal","adm1name":"Azores","iso_a2":"PT"},"coordinates":[428704,728355]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Vigo","adm0name":"Spain","adm1name":"Galicia","iso_a2":"ES"},"coordinates":[475750,754847]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Bilbao","adm0name":"Spain","adm1name":"País Vasco","iso_a2":"ES"},"coordinates":[491861,760950]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Kaolack","adm0name":"Senegal","adm1name":"Kaolack","iso_a2":"SM"},"coordinates":[455277,588548]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Kaedi","adm0name":"Senegal","adm1name":"Matam","iso_a2":"SM"},"coordinates":[462500,600397]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Geneina","adm0name":"Sudan","adm1name":"West Darfur","iso_a2":"SD"},"coordinates":[562333,584401]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Medina","adm0name":"Saudi Arabia","adm1name":"Al Madinah","iso_a2":"SA"},"coordinates":[609939,649878]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Tabuk","adm0name":"Saudi Arabia","adm1name":"Tabuk","iso_a2":"SA"},"coordinates":[601541,672875]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Juba","adm0name":"South Sudan","adm1name":"Central Equatoria","iso_a2":"SS"},"coordinates":[587722,533332]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Malakal","adm0name":"South Sudan","adm1name":"Upper Nile","iso_a2":"SS"},"coordinates":[587933,561218]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Omdurman","adm0name":"Sudan","adm1name":"Khartoum","iso_a2":"SD"},"coordinates":[590222,597237]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"El Obeid","adm0name":"Sudan","adm1name":"North Kurdufan","iso_a2":"SD"},"coordinates":[583935,582821]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"The Hague","adm0name":"Netherlands","adm1name":"Zuid-Holland","iso_a2":"NL"},"coordinates":[511860,813263]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Kristiansand","adm0name":"Norway","adm1name":"Vest-Agder","iso_a2":"NO"},"coordinates":[522222,849323]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Ljubljana","adm0name":"Slovenia","adm1name":"Osrednjeslovenska","iso_a2":"SI"},"coordinates":[540318,777570]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Bratislava","adm0name":"Slovakia","adm1name":"Bratislavský","iso_a2":"SK"},"coordinates":[547547,789979]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Hammerfest","adm0name":"Norway","adm1name":"Finnmark","iso_a2":"NO"},"coordinates":[565800,923346]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Doha","adm0name":"Qatar","adm1name":"Ad Dawhah","iso_a2":"QA"},"coordinates":[643147,654526]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Quetta","adm0name":"Pakistan","adm1name":"Baluchistan","iso_a2":"PK"},"coordinates":[686175,683766]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Larkana","adm0name":"Pakistan","adm1name":"Sind","iso_a2":"PK"},"coordinates":[689463,668005]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Springbok","adm0name":"South Africa","adm1name":"Northern Cape","iso_a2":"ZA"},"coordinates":[549675,328958]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Upington","adm0name":"South Africa","adm1name":"Northern Cape","iso_a2":"ZA"},"coordinates":[558972,336107]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Worcester","adm0name":"South Africa","adm1name":"Western Cape","iso_a2":"ZA"},"coordinates":[553999,305418]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"George","adm0name":"South Africa","adm1name":"Western Cape","iso_a2":"ZA"},"coordinates":[562360,303582]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Tete","adm0name":"Mozambique","adm1name":"Tete","iso_a2":"MZ"},"coordinates":[593277,408919]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Pemba","adm0name":"Mozambique","adm1name":"Cabo Delgado","iso_a2":"MZ"},"coordinates":[612589,427799]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Nampula","adm0name":"Mozambique","adm1name":"Nampula","iso_a2":"MZ"},"coordinates":[609147,415044]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Welkom","adm0name":"South Africa","adm1name":"Orange Free State","iso_a2":"ZA"},"coordinates":[574249,339011]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Xai-Xai","adm0name":"Mozambique","adm1name":"Gaza","iso_a2":"MZ"},"coordinates":[593443,356369]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Goroka","adm0name":"Papua New Guinea","adm1name":"Eastern Highlands","iso_a2":"PG"},"coordinates":[903848,468677]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Mt. Hagen","adm0name":"Papua New Guinea","adm1name":"Western Highlands","iso_a2":"PG"},"coordinates":[900601,469980]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Rabaul","adm0name":"Papua New Guinea","adm1name":"East New Britain","iso_a2":"PG"},"coordinates":[922620,479802]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Lae","adm0name":"Papua New Guinea","adm1name":"Morobe","iso_a2":"PG"},"coordinates":[908305,464828]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"David","adm0name":"Panama","adm1name":"Chiriquí","iso_a2":"PA"},"coordinates":[271018,554680]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Oujda","adm0name":"Morocco","adm1name":"Oriental","iso_a2":"MA"},"coordinates":[494694,710237]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Safi","adm0name":"Morocco","adm1name":"Doukkala - Abda","iso_a2":"MA"},"coordinates":[474333,696195]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Podgorica","adm0name":"Montenegro","adm1name":"Podgorica","iso_a2":"ME"},"coordinates":[553517,756305]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Quelimane","adm0name":"Mozambique","adm1name":"Zambezia","iso_a2":"MZ"},"coordinates":[602472,398787]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"East London","adm0name":"South Africa","adm1name":"Eastern Cape","iso_a2":"ZA"},"coordinates":[577416,309388]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Middelburg","adm0name":"South Africa","adm1name":"Eastern Cape","iso_a2":"ZA"},"coordinates":[569472,318097]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Naltchik","adm0name":"Russia","adm1name":"Kabardin-Balkar","iso_a2":"RU"},"coordinates":[621161,762420]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Stavropol","adm0name":"Russia","adm1name":"Stavropol'","iso_a2":"RU"},"coordinates":[616610,771613]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ugolnye Kopi","adm0name":"Russia","adm1name":"Chukchi Autonomous Okrug","iso_a2":"RU"},"coordinates":[993610,888227]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kaliningrad","adm0name":"Russia","adm1name":"Kaliningrad","iso_a2":"RU"},"coordinates":[556937,828785]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Pskov","adm0name":"Russia","adm1name":"Pskov","iso_a2":"RU"},"coordinates":[578694,847328]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Bryansk","adm0name":"Russia","adm1name":"Bryansk","iso_a2":"RU"},"coordinates":[595638,820254]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Smolensk","adm0name":"Russia","adm1name":"Smolensk","iso_a2":"RU"},"coordinates":[589020,829274]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Petrozavodsk","adm0name":"Russia","adm1name":"Karelia","iso_a2":"RU"},"coordinates":[595222,871144]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Tver","adm0name":"Russia","adm1name":"Tver'","iso_a2":"RU"},"coordinates":[599693,841581]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Vologda","adm0name":"Russia","adm1name":"Vologda","iso_a2":"RU"},"coordinates":[610888,855504]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Yaroslavl","adm0name":"Russia","adm1name":"Yaroslavl'","iso_a2":"RU"},"coordinates":[610749,846084]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Rostov","adm0name":"Russia","adm1name":"Rostov","iso_a2":"RU"},"coordinates":[610307,784568]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Sochi","adm0name":"Russia","adm1name":"Krasnodar","iso_a2":"RU"},"coordinates":[610360,762964]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Krasnodar","adm0name":"Russia","adm1name":"Krasnodar","iso_a2":"RU"},"coordinates":[608333,771435]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Penza","adm0name":"Russia","adm1name":"Penza","iso_a2":"RU"},"coordinates":[624999,819779]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ryazan","adm0name":"Russia","adm1name":"Ryazan'","iso_a2":"RU"},"coordinates":[610333,828311]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Voronezh","adm0name":"Russia","adm1name":"Voronezh","iso_a2":"RU"},"coordinates":[609077,811201]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Magnitogorsk","adm0name":"Russia","adm1name":"Chelyabinsk","iso_a2":"RU"},"coordinates":[663833,821217]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Chelyabinsk","adm0name":"Russia","adm1name":"Chelyabinsk","iso_a2":"RU"},"coordinates":[670657,831492]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Vorkuta","adm0name":"Russia","adm1name":"Komi","iso_a2":"RU"},"coordinates":[677805,904617]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kirov","adm0name":"Russia","adm1name":"Kirov","iso_a2":"RU"},"coordinates":[637971,851831]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Nizhny Tagil","adm0name":"Russia","adm1name":"Sverdlovsk","iso_a2":"RU"},"coordinates":[666597,847862]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Astrakhan","adm0name":"Russia","adm1name":"Astrakhan'","iso_a2":"RU"},"coordinates":[633486,779307]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Orenburg","adm0name":"Russia","adm1name":"Orenburg","iso_a2":"RU"},"coordinates":[653083,811485]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Saratov","adm0name":"Russia","adm1name":"Saratov","iso_a2":"RU"},"coordinates":[627855,810312]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ulyanovsk","adm0name":"Russia","adm1name":"Ul'yanovsk","iso_a2":"RU"},"coordinates":[634471,826592]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Omsk","adm0name":"Russia","adm1name":"Omsk","iso_a2":"RU"},"coordinates":[703882,830514]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Tyumen","adm0name":"Russia","adm1name":"Tyumen'","iso_a2":"RU"},"coordinates":[682027,843241]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Novokuznetsk","adm0name":"Russia","adm1name":"Kemerovo","iso_a2":"RU"},"coordinates":[741986,823156]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kemerovo","adm0name":"Russia","adm1name":"Kemerovo","iso_a2":"RU"},"coordinates":[739139,832576]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Groznyy","adm0name":"Russia","adm1name":"Chechnya","iso_a2":"RU"},"coordinates":[626940,761357]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Kandy","adm0name":"Sri Lanka","adm1name":"Kandy","iso_a2":"LK"},"coordinates":[724082,547847]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Sri Jawewardenepura Kotte","adm0name":"Sri Lanka","adm1name":"Colombo","iso_a2":"LK"},"coordinates":[722082,545596]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Daejeon","adm0name":"South Korea","adm1name":"Daejeon","iso_a2":"KR"},"coordinates":[853952,719997]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Gwangju","adm0name":"South Korea","adm1name":"Kwangju-gwangyoksi","iso_a2":"KR"},"coordinates":[852523,713097]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Busan","adm0name":"South Korea","adm1name":"Busan","iso_a2":"KR"},"coordinates":[858355,712648]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Zamboanga","adm0name":"Philippines","adm1name":"Zamboanga del Sur","iso_a2":"PH"},"coordinates":[839105,545725]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Laoag","adm0name":"Philippines","adm1name":"Ilocos Norte","iso_a2":"PH"},"coordinates":[834981,612535]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Baguio City","adm0name":"Philippines","adm1name":"Benguet","iso_a2":"PH"},"coordinates":[834915,602056]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"General Santos","adm0name":"Philippines","adm1name":"South Cotabato","iso_a2":"PH"},"coordinates":[847707,540920]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ust-Ulimsk","adm0name":"Russia","adm1name":"Irkutsk","iso_a2":"RU"},"coordinates":[785092,848276]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Angarsk","adm0name":"Russia","adm1name":"Irkutsk","iso_a2":"RU"},"coordinates":[788666,816107]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Abakan","adm0name":"Russia","adm1name":"Krasnoyarsk","iso_a2":"RU"},"coordinates":[754013,822882]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Norilsk","adm0name":"Russia","adm1name":"Taymyr","iso_a2":"RU"},"coordinates":[745068,915519]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Khatanga","adm0name":"Russia","adm1name":"Taymyr","iso_a2":"RU"},"coordinates":[784624,931522]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kyzyl","adm0name":"Russia","adm1name":"Tuva","iso_a2":"RU"},"coordinates":[762175,811051]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ulan Ude","adm0name":"Russia","adm1name":"Buryat","iso_a2":"RU"},"coordinates":[798958,811752]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Blagoveshchensk","adm0name":"Russia","adm1name":"Amur","iso_a2":"RU"},"coordinates":[854259,802519]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Bukachacha","adm0name":"Russia","adm1name":"Chita","iso_a2":"RU"},"coordinates":[824768,818614]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Dalnegorsk","adm0name":"Russia","adm1name":"Primor'ye","iso_a2":"RU"},"coordinates":[876436,768575]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ambarchik","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[950925,917361]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Batagay","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[873985,905542]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Chokurdakh","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[910817,923092]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ust Nera","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[897777,887239]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Lensk","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[819296,864481]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Aldan","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[848303,851908]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Mirnyy","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[816558,875232]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Zhigansk","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[842697,900291]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Okhotsk","adm0name":"Russia","adm1name":"Khabarovsk","iso_a2":"RU"},"coordinates":[897824,856529]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Khabarovsk","adm0name":"Russia","adm1name":"Khabarovsk","iso_a2":"RU"},"coordinates":[875333,791786]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Okha","adm0name":"Russia","adm1name":"Sakhalin","iso_a2":"RU"},"coordinates":[897076,822113]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Yuzhno Sakhalinsk","adm0name":"Russia","adm1name":"Sakhalin","iso_a2":"RU"},"coordinates":[896500,782959]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Mexicali","adm0name":"Mexico","adm1name":"Baja California","iso_a2":"MX"},"coordinates":[179216,698162]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"La Paz","adm0name":"Mexico","adm1name":"Baja California Sur","iso_a2":"MX"},"coordinates":[193556,647733]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Torreon","adm0name":"Mexico","adm1name":"Coahuila","iso_a2":"MX"},"coordinates":[212716,656217]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Culiacan","adm0name":"Mexico","adm1name":"Sinaloa","iso_a2":"MX"},"coordinates":[201716,651833]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Nogales","adm0name":"Mexico","adm1name":"Sonora","iso_a2":"MX"},"coordinates":[191820,690182]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Hermosillo","adm0name":"Mexico","adm1name":"Sonora","iso_a2":"MX"},"coordinates":[191794,677112]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Guaymas","adm0name":"Mexico","adm1name":"Sonora","iso_a2":"MX"},"coordinates":[191972,670187]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"San Luis Potosi","adm0name":"Mexico","adm1name":"San Luis Potosí","iso_a2":"MX"},"coordinates":[219439,636074]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Matamoros","adm0name":"Mexico","adm1name":"Tamaulipas","iso_a2":"MX"},"coordinates":[229166,658042]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Nuevo Laredo","adm0name":"Mexico","adm1name":"Tamaulipas","iso_a2":"MX"},"coordinates":[223472,667639]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Colima","adm0name":"Mexico","adm1name":"Colima","iso_a2":"MX"},"coordinates":[211889,618644]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Campeche","adm0name":"Mexico","adm1name":"Campeche","iso_a2":"MX"},"coordinates":[248611,622199]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Oaxaca","adm0name":"Mexico","adm1name":"Oaxaca","iso_a2":"MX"},"coordinates":[231472,605923]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Leon","adm0name":"Mexico","adm1name":"Guanajuato","iso_a2":"MX"},"coordinates":[217495,630031]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Maiduguri","adm0name":"Nigeria","adm1name":"Borno","iso_a2":"NG"},"coordinates":[536550,574933]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Port Harcourt","adm0name":"Nigeria","adm1name":"Rivers","iso_a2":"NG"},"coordinates":[519466,533225]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Makurdi","adm0name":"Nigeria","adm1name":"Benue","iso_a2":"NG"},"coordinates":[523694,550513]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ibadan","adm0name":"Nigeria","adm1name":"Oyo","iso_a2":"NG"},"coordinates":[510910,548451]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Ogbomosho","adm0name":"Nigeria","adm1name":"Oyo","iso_a2":"NG"},"coordinates":[511772,552894]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Warri","adm0name":"Nigeria","adm1name":"Delta","iso_a2":"NG"},"coordinates":[515999,537420]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kaduna","adm0name":"Nigeria","adm1name":"Kaduna","iso_a2":"NG"},"coordinates":[520661,567054]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Gdansk","adm0name":"Poland","adm1name":"Pomeranian","iso_a2":"PL"},"coordinates":[551777,826770]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Kraków","adm0name":"Poland","adm1name":"Lesser Poland","iso_a2":"PL"},"coordinates":[555438,801306]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Dalandzadgad","adm0name":"Mongolia","adm1name":"Ömnögovi","iso_a2":"MN"},"coordinates":[790111,762926]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Wonsan","adm0name":"North Korea","adm1name":"Kangwon-do","iso_a2":"KP"},"coordinates":[853974,736721]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Sinuiju","adm0name":"North Korea","adm1name":"P'yongan-bukto","iso_a2":"KP"},"coordinates":[845613,742204]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Dund-Us","adm0name":"Mongolia","adm1name":"Hovd","iso_a2":"MN"},"coordinates":[754536,789189]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Choybalsan","adm0name":"Mongolia","adm1name":"Dornod","iso_a2":"MN"},"coordinates":[818071,789486]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Lüderitz","adm0name":"Namibia","adm1name":"Karas","iso_a2":"NA"},"coordinates":[542109,346842]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Walvis Bay","adm0name":"Namibia","adm1name":"Erongo","iso_a2":"NA"},"coordinates":[540292,368707]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Mwanza","adm0name":"Tanzania","adm1name":"Mwanza","iso_a2":"TZ"},"coordinates":[591472,489788]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Morogoro","adm0name":"Tanzania","adm1name":"Morogoro","iso_a2":"TZ"},"coordinates":[604610,464312]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Dodoma","adm0name":"Tanzania","adm1name":"Dodoma","iso_a2":"TZ"},"coordinates":[599305,468084]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Arusha","adm0name":"Tanzania","adm1name":"Arusha","iso_a2":"TZ"},"coordinates":[601861,484811]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Napier","adm0name":"New Zealand","adm1name":"Gisborne","iso_a2":"NZ"},"coordinates":[991429,270760]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Manukau","adm0name":"New Zealand","adm1name":"Auckland","iso_a2":"NZ"},"coordinates":[985790,285513]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Hamilton","adm0name":"New Zealand","adm1name":"Auckland","iso_a2":"NZ"},"coordinates":[986944,280950]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Blenheim","adm0name":"New Zealand","adm1name":"Marlborough","iso_a2":"NZ"},"coordinates":[983220,258728]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Dunedin","adm0name":"New Zealand","adm1name":"Otago","iso_a2":"NZ"},"coordinates":[973555,232904]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Bern","adm0name":"Switzerland","adm1name":"Bern","iso_a2":"CH"},"coordinates":[520741,782673]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Malmö","adm0name":"Sweden","adm1name":"Skåne","iso_a2":"SE"},"coordinates":[536203,834018]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Laayoune","adm0name":"Morocco","adm1name":"Laâyoune - Boujdour - Sakia El Hamra","iso_a2":"MA"},"coordinates":[463333,665566]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ternate","adm0name":"Indonesia","adm1name":"Maluku Utara","iso_a2":"ID"},"coordinates":[853785,509415]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ambon","adm0name":"Indonesia","adm1name":"Maluku","iso_a2":"ID"},"coordinates":[856110,482698]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Raba","adm0name":"Indonesia","adm1name":"Nusa Tenggara Barat","iso_a2":"ID"},"coordinates":[829907,454656]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jayapura","adm0name":"Indonesia","adm1name":"Papua","iso_a2":"ID"},"coordinates":[890832,489710]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Florence","adm0name":"Italy","adm1name":"Toscana","iso_a2":"IT"},"coordinates":[531250,764090]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Catania","adm0name":"Italy","adm1name":"Sicily","iso_a2":"IT"},"coordinates":[541888,726884]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Pristina","adm0name":"Kosovo","adm1name":"Pristina","iso_a2":"-99"},"coordinates":[558793,757494]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Meru","adm0name":"Kenya","adm1name":"Eastern","iso_a2":"KE"},"coordinates":[604555,505072]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Eldoret","adm0name":"Kenya","adm1name":"Rift Valley","iso_a2":"KE"},"coordinates":[597971,507798]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Banda Aceh","adm0name":"Indonesia","adm1name":"Aceh","iso_a2":"ID"},"coordinates":[764777,537597]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"George Town","adm0name":"Malaysia","adm1name":"Pulau Pinang","iso_a2":"MY"},"coordinates":[778692,536790]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Zhangye","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[779027,735356]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Wuwei","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[785113,729420]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Dunhuang","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[762949,742540]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tianshui","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[794216,709715]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Dulan","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[772962,718985]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Golmud","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[763564,720465]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Yulin","adm0name":"China","adm1name":"Guangxi","iso_a2":"CN"},"coordinates":[805966,638799]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Bose","adm0name":"China","adm1name":"Guangxi","iso_a2":"CN"},"coordinates":[796147,646309]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Wuzhou","adm0name":"China","adm1name":"Guangxi","iso_a2":"CN"},"coordinates":[809222,643823]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Lupanshui","adm0name":"China","adm1name":"Guizhou","iso_a2":"CN"},"coordinates":[791198,662286]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Quanzhou","adm0name":"China","adm1name":"Fujian","iso_a2":"CN"},"coordinates":[829382,652248]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Hefei","adm0name":"China","adm1name":"Anhui","iso_a2":"CN"},"coordinates":[825772,693423]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Suzhou","adm0name":"China","adm1name":"Anhui","iso_a2":"CN"},"coordinates":[824935,704004]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Zhanjiang","adm0name":"China","adm1name":"Guangdong","iso_a2":"CN"},"coordinates":[806605,630327]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Shaoguan","adm0name":"China","adm1name":"Guangdong","iso_a2":"CN"},"coordinates":[815499,651643]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Balikpapan","adm0name":"Indonesia","adm1name":"Kalimantan Timur","iso_a2":"ID"},"coordinates":[824527,497311]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Kuching","adm0name":"Malaysia","adm1name":"Sarawak","iso_a2":"MY"},"coordinates":[806472,513781]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Antsiranana","adm0name":"Madagascar","adm1name":"Antsiranana","iso_a2":"MG"},"coordinates":[636976,431985]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Fianarantsoa","adm0name":"Madagascar","adm1name":"Fianarantsoa","iso_a2":"MG"},"coordinates":[630786,377736]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Mahajanga","adm0name":"Madagascar","adm1name":"Mahajanga","iso_a2":"MG"},"coordinates":[628736,411881]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Toliara","adm0name":"Madagascar","adm1name":"Toliary","iso_a2":"MG"},"coordinates":[621360,366340]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Surakarta","adm0name":"Indonesia","adm1name":"Jawa Tengah","iso_a2":"ID"},"coordinates":[807846,459899]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Bandar Lampung","adm0name":"Indonesia","adm1name":"Lampung","iso_a2":"ID"},"coordinates":[792411,472559]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tanjungpandan","adm0name":"Indonesia","adm1name":"Bangka-Belitung","iso_a2":"ID"},"coordinates":[799027,488425]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Malang","adm0name":"Indonesia","adm1name":"Jawa Timur","iso_a2":"ID"},"coordinates":[812800,457451]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Kupang","adm0name":"Indonesia","adm1name":"Nusa Tenggara Timur","iso_a2":"ID"},"coordinates":[843285,444414]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Parepare","adm0name":"Indonesia","adm1name":"Sulawesi Selatan","iso_a2":"ID"},"coordinates":[832314,480920]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Cuenca","adm0name":"Ecuador","adm1name":"Azuay","iso_a2":"EC"},"coordinates":[280555,487536]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Santa Cruz","adm0name":"Ecuador","adm1name":"Galápagos","iso_a2":"EC"},"coordinates":[249027,501557]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Puerto Limon","adm0name":"Costa Rica","adm1name":"Limón","iso_a2":"CR"},"coordinates":[269351,563962]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Santiago de Cuba","adm0name":"Cuba","adm1name":"Santiago de Cuba","iso_a2":"CU"},"coordinates":[289385,623354]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Santiago","adm0name":"Dominican Republic","adm1name":"Santiago","iso_a2":"DO"},"coordinates":[303694,620244]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Manizales","adm0name":"Colombia","adm1name":"Caldas","iso_a2":"CO"},"coordinates":[290222,534695]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Pasto","adm0name":"Colombia","adm1name":"Nariño","iso_a2":"CO"},"coordinates":[285330,511907]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Barranquilla","adm0name":"Colombia","adm1name":"Atlántico","iso_a2":"CO"},"coordinates":[292217,569660]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Roseau","adm0name":"Dominica","adm1name":"Saint George","iso_a2":"DM"},"coordinates":[329480,595367]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Mbandaka","adm0name":"Congo (Kinshasa)","adm1name":"Équateur","iso_a2":"CD"},"coordinates":[550722,504955]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Moundou","adm0name":"Chad","adm1name":"Logone Oriental","iso_a2":"TD"},"coordinates":[544694,555371]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Suez","adm0name":"Egypt","adm1name":"As Suways","iso_a2":"EG"},"coordinates":[590416,682480]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Bur Said","adm0name":"Egypt","adm1name":"Bur Sa`id","iso_a2":"EG"},"coordinates":[589694,689915]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"El Faiyum","adm0name":"Egypt","adm1name":"Al Fayyum","iso_a2":"EG"},"coordinates":[585666,678363]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Aswan","adm0name":"Egypt","adm1name":"Aswan","iso_a2":"EG"},"coordinates":[591385,647422]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Asyut","adm0name":"Egypt","adm1name":"Asyut","iso_a2":"EG"},"coordinates":[586610,665803]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Kisangani","adm0name":"Congo (Kinshasa)","adm1name":"Orientale","iso_a2":"CD"},"coordinates":[570055,507798]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Assab","adm0name":"Eritrea","adm1name":"Debubawi Keyih Bahri","iso_a2":"ER"},"coordinates":[618694,581794]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Djibouti","adm0name":"Djibouti","adm1name":"Djibouti","iso_a2":"DJ"},"coordinates":[619855,573411]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Dresden","adm0name":"Germany","adm1name":"Sachsen","iso_a2":"DE"},"coordinates":[538194,807160]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Xigaze","adm0name":"China","adm1name":"Xizang","iso_a2":"CN"},"coordinates":[746897,678007]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Shache","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[714583,732371]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Yining","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[725971,764800]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Altay","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[744768,788301]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Putrajaya","adm0name":"Malaysia","adm1name":"Selangor","iso_a2":"MY"},"coordinates":[782504,521981]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Shizuishan","adm0name":"China","adm1name":"Ningxia Hui","iso_a2":"CN"},"coordinates":[796580,737152]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Yulin","adm0name":"China","adm1name":"Shaanxi","iso_a2":"CN"},"coordinates":[804814,731525]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ankang","adm0name":"China","adm1name":"Shaanxi","iso_a2":"CN"},"coordinates":[802832,698328]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Houma","adm0name":"China","adm1name":"Shanxi","iso_a2":"CN"},"coordinates":[808916,715746]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Yueyang","adm0name":"China","adm1name":"Hunan","iso_a2":"CN"},"coordinates":[814160,678790]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Hengyang","adm0name":"China","adm1name":"Hunan","iso_a2":"CN"},"coordinates":[812744,663978]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Mianyang","adm0name":"China","adm1name":"Sichuan","iso_a2":"CN"},"coordinates":[791022,691171]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Xichang","adm0name":"China","adm1name":"Sichuan","iso_a2":"CN"},"coordinates":[784166,669891]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Baoshan","adm0name":"China","adm1name":"Yunnan","iso_a2":"CN"},"coordinates":[775415,653540]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Gejiu","adm0name":"China","adm1name":"Yunnan","iso_a2":"CN"},"coordinates":[786527,643230]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Shijianzhuang","adm0name":"China","adm1name":"Hebei","iso_a2":"CN"},"coordinates":[817993,730154]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Handan","adm0name":"China","adm1name":"Hebei","iso_a2":"CN"},"coordinates":[817993,721445]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Anshan","adm0name":"China","adm1name":"Liaoning","iso_a2":"CN"},"coordinates":[841494,748312]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Dalian","adm0name":"China","adm1name":"Liaoning","iso_a2":"CN"},"coordinates":[837855,735325]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Qingdao","adm0name":"China","adm1name":"Shandong","iso_a2":"CN"},"coordinates":[834244,718542]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Linyi","adm0name":"China","adm1name":"Shandong","iso_a2":"CN"},"coordinates":[828688,712559]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Huaiyin","adm0name":"China","adm1name":"Jiangsu","iso_a2":"CN"},"coordinates":[830633,703671]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Wenzhou","adm0name":"China","adm1name":"Zhejiang","iso_a2":"CN"},"coordinates":[835133,670731]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ningbo","adm0name":"China","adm1name":"Zhejiang","iso_a2":"CN"},"coordinates":[837632,681751]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Fukuoka","adm0name":"Japan","adm1name":"Fukuoka","iso_a2":"JP"},"coordinates":[862243,703760]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Miyazaki","adm0name":"Japan","adm1name":"Miyazaki","iso_a2":"JP"},"coordinates":[865050,693815]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Naha","adm0name":"Japan","adm1name":"Okinawa","iso_a2":"JP"},"coordinates":[854647,659980]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kochi","adm0name":"Japan","adm1name":"Kochi","iso_a2":"JP"},"coordinates":[870937,703556]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Gorontalo","adm0name":"Indonesia","adm1name":"Gorontalo","iso_a2":"ID"},"coordinates":[841861,507976]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tongliao","adm0name":"China","adm1name":"Nei Mongol","iso_a2":"CN"},"coordinates":[839633,763153]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Hohhot","adm0name":"China","adm1name":"Nei Mongol","iso_a2":"CN"},"coordinates":[810161,746565]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Chifeng","adm0name":"China","adm1name":"Nei Mongol","iso_a2":"CN"},"coordinates":[830410,755155]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ulanhot","adm0name":"China","adm1name":"Nei Mongol","iso_a2":"CN"},"coordinates":[839110,777716]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Hailar","adm0name":"China","adm1name":"Nei Mongol","iso_a2":"CN"},"coordinates":[832499,796200]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jiamusi","adm0name":"China","adm1name":"Heilongjiang","iso_a2":"CN"},"coordinates":[862077,782171]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Beian","adm0name":"China","adm1name":"Heilongjiang","iso_a2":"CN"},"coordinates":[851338,790507]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Daqing","adm0name":"China","adm1name":"Heilongjiang","iso_a2":"CN"},"coordinates":[847216,780690]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jixi","adm0name":"China","adm1name":"Heilongjiang","iso_a2":"CN"},"coordinates":[863800,773106]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Nagoya","adm0name":"Japan","adm1name":"Aichi","iso_a2":"JP"},"coordinates":[880313,713003]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Nagano","adm0name":"Japan","adm1name":"Nagano","iso_a2":"JP"},"coordinates":[883805,721849]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kushiro","adm0name":"Japan","adm1name":"Hokkaido","iso_a2":"JP"},"coordinates":[901040,759320]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Hakodate","adm0name":"Japan","adm1name":"Hokkaido","iso_a2":"JP"},"coordinates":[890943,752329]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kyoto","adm0name":"Japan","adm1name":"Kyoto","iso_a2":"JP"},"coordinates":[877077,712262]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Sendai","adm0name":"Japan","adm1name":"Miyagi","iso_a2":"JP"},"coordinates":[891720,731559]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Sakata","adm0name":"Japan","adm1name":"Yamagata","iso_a2":"JP"},"coordinates":[888471,735297]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Bandundu","adm0name":"Congo (Kinshasa)","adm1name":"Bandundu","iso_a2":"CD"},"coordinates":[548277,485107]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Kananga","adm0name":"Congo (Kinshasa)","adm1name":"Kasaï-Occidental","iso_a2":"CD"},"coordinates":[562216,469833]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Kasongo","adm0name":"Congo (Kinshasa)","adm1name":"Maniema","iso_a2":"CD"},"coordinates":[574055,478353]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Mbuji-Mayi","adm0name":"Congo (Kinshasa)","adm1name":"Kasaï-Oriental","iso_a2":"CD"},"coordinates":[565549,468293]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Kalemie","adm0name":"Congo (Kinshasa)","adm1name":"Katanga","iso_a2":"CD"},"coordinates":[581110,469566]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Butembo","adm0name":"Congo (Kinshasa)","adm1name":"Nord-Kivu","iso_a2":"CD"},"coordinates":[581332,505487]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Goma","adm0name":"Congo (Kinshasa)","adm1name":"Nord-Kivu","iso_a2":"CD"},"coordinates":[581171,494771]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Mzuzu","adm0name":"Malawi","adm1name":"Mzimba","iso_a2":"MW"},"coordinates":[594500,436823]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Blantyre","adm0name":"Malawi","adm1name":"Blantyre","iso_a2":"MW"},"coordinates":[597193,411170]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Quetzaltenango","adm0name":"Guatemala","adm1name":"Quezaltenango","iso_a2":"GT"},"coordinates":[245778,592576]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Banjul","adm0name":"The Gambia","adm1name":"Banjul","iso_a2":"GM"},"coordinates":[453912,584424]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Faridabad","adm0name":"India","adm1name":"Haryana","iso_a2":"IN"},"coordinates":[714762,673180]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Srinagar","adm0name":"India","adm1name":"Jammu and Kashmir","iso_a2":"IN"},"coordinates":[707813,706752]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Vijayawada","adm0name":"India","adm1name":"Andhra Pradesh","iso_a2":"IN"},"coordinates":[723966,602600]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Thiruvananthapuram","adm0name":"India","adm1name":"Kerala","iso_a2":"IN"},"coordinates":[713744,555086]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Kochi","adm0name":"India","adm1name":"Kerala","iso_a2":"IN"},"coordinates":[711727,564062]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Cuttack","adm0name":"India","adm1name":"Orissa","iso_a2":"IN"},"coordinates":[738583,625991]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Hubli","adm0name":"India","adm1name":"Karnataka","iso_a2":"IN"},"coordinates":[708674,595728]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Mangalore","adm0name":"India","adm1name":"Karnataka","iso_a2":"IN"},"coordinates":[707916,581143]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Mysore","adm0name":"India","adm1name":"Karnataka","iso_a2":"IN"},"coordinates":[712938,577658]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Gulbarga","adm0name":"India","adm1name":"Karnataka","iso_a2":"IN"},"coordinates":[713388,607506]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Kolhapur","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[706166,603655]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Nanded","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[714721,618289]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Akola","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[713916,627412]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Guwahati","adm0name":"India","adm1name":"Assam","iso_a2":"IN"},"coordinates":[754911,659712]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Kayes","adm0name":"Congo (Brazzaville)","adm1name":"Bouenza","iso_a2":"CG"},"coordinates":[536888,479953]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Franceville","adm0name":"Gabon","adm1name":"Haut-Ogooué","iso_a2":"GA"},"coordinates":[537731,495041]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Bordeaux","adm0name":"France","adm1name":"Aquitaine","iso_a2":"FR"},"coordinates":[498342,770440]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Marseille","adm0name":"France","adm1name":"Provence-Alpes-Côte-d'Azur","iso_a2":"FR"},"coordinates":[514925,761198]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Le Havre","adm0name":"France","adm1name":"Haute-Normandie","iso_a2":"FR"},"coordinates":[500291,798007]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Gao","adm0name":"Mali","adm1name":"Gao","iso_a2":"ML"},"coordinates":[499860,601088]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Coihaique","adm0name":"Chile","adm1name":"Aisén del General Carlos Ibáñez del Campo","iso_a2":"CL"},"coordinates":[299806,234740]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Arica","adm0name":"Chile","adm1name":"Arica y Parinacota","iso_a2":"CL"},"coordinates":[304750,395115]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Copiapo","adm0name":"Chile","adm1name":"Atacama","iso_a2":"CL"},"coordinates":[304610,342624]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"La Serena","adm0name":"Chile","adm1name":"Coquimbo","iso_a2":"CL"},"coordinates":[302083,327576]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Los Angeles","adm0name":"Chile","adm1name":"Bío-Bío","iso_a2":"CL"},"coordinates":[299000,282787]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Narsarsuaq","adm0name":"Greenland","adm1name":"Kommune Kujalleq","iso_a2":"GL"},"coordinates":[373843,867096]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Sisimiut","adm0name":"Greenland","adm1name":"Qeqqata Kommunia","iso_a2":"GL"},"coordinates":[350925,901360]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Upernavik","adm0name":"Greenland","adm1name":"Qaasuitsup Kommunia","iso_a2":"GL"},"coordinates":[344051,935480]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Qaanaaq","adm0name":"Greenland","adm1name":"Qaasuitsup Kommunia","iso_a2":"GL"},"coordinates":[307410,963764]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Nouadhibou","adm0name":"Mauritania","adm1name":"Dakhlet Nouadhibou","iso_a2":"MR"},"coordinates":[452622,628538]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Kayes","adm0name":"Mali","adm1name":"Kayes","iso_a2":"ML"},"coordinates":[468221,590325]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Ayoun el Atrous","adm0name":"Mauritania","adm1name":"Hodh el Gharbi","iso_a2":"MR"},"coordinates":[473287,603457]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Segou","adm0name":"Mali","adm1name":"Ségou","iso_a2":"ML"},"coordinates":[482611,584342]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Skopje","adm0name":"Macedonia","adm1name":"Centar","iso_a2":"MK"},"coordinates":[559537,753544]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Al Jawf","adm0name":"Libya","adm1name":"Al Kufrah","iso_a2":"LY"},"coordinates":[564694,648089]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Tmassah","adm0name":"Libya","adm1name":"Murzuq","iso_a2":"LY"},"coordinates":[543888,660925]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Misratah","adm0name":"Libya","adm1name":"Misratah","iso_a2":"LY"},"coordinates":[541944,696550]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Zuwarah","adm0name":"Libya","adm1name":"An Nuqat al Khams","iso_a2":"LY"},"coordinates":[533553,699835]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Kirkuk","adm0name":"Iraq","adm1name":"At-Ta'mim","iso_a2":"IQ"},"coordinates":[623311,714871]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Mosul","adm0name":"Iraq","adm1name":"Ninawa","iso_a2":"IQ"},"coordinates":[619841,720053]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"An Najaf","adm0name":"Iraq","adm1name":"An-Najaf","iso_a2":"IQ"},"coordinates":[623153,694302]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Bahir Dar","adm0name":"Ethiopia","adm1name":"Amhara","iso_a2":"ET"},"coordinates":[603842,573441]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Mekele","adm0name":"Ethiopia","adm1name":"Tigray","iso_a2":"ET"},"coordinates":[609638,584697]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Dire Dawa","adm0name":"Ethiopia","adm1name":"Dire Dawa","iso_a2":"ET"},"coordinates":[616277,561532]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Rovaniemi","adm0name":"Finland","adm1name":"Lapland","iso_a2":"FI"},"coordinates":[571433,898693]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Vaasa","adm0name":"Finland","adm1name":"Western Finland","iso_a2":"FI"},"coordinates":[560000,878550]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Tampere","adm0name":"Finland","adm1name":"Pirkanmaa","iso_a2":"FI"},"coordinates":[565972,869071]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Aqtobe","adm0name":"Kazakhstan","adm1name":"Aqtöbe","iso_a2":"KZ"},"coordinates":[658805,802598]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Rudny","adm0name":"Kazakhstan","adm1name":"Qostanay","iso_a2":"KZ"},"coordinates":[675360,818432]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Qyzylorda","adm0name":"Kazakhstan","adm1name":"Qyzylorda","iso_a2":"KZ"},"coordinates":[681846,770133]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Atyrau","adm0name":"Kazakhstan","adm1name":"Atyrau","iso_a2":"KZ"},"coordinates":[644221,783834]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Ekibastuz","adm0name":"Kazakhstan","adm1name":"Pavlodar","iso_a2":"KZ"},"coordinates":[709222,811189]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Pavlodar","adm0name":"Kazakhstan","adm1name":"Pavlodar","iso_a2":"KZ"},"coordinates":[713750,814566]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Semey","adm0name":"Kazakhstan","adm1name":"East Kazakhstan","iso_a2":"KZ"},"coordinates":[722985,803517]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Oskemen","adm0name":"Kazakhstan","adm1name":"East Kazakhstan","iso_a2":"KZ"},"coordinates":[729486,800881]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Yazd","adm0name":"Iran","adm1name":"Yazd","iso_a2":"IR"},"coordinates":[651028,693826]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Ahvaz","adm0name":"Iran","adm1name":"Khuzestan","iso_a2":"IR"},"coordinates":[635327,690046]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Basra","adm0name":"Iraq","adm1name":"Al-Basrah","iso_a2":"IQ"},"coordinates":[632809,685504]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Bandar-e-Abbas","adm0name":"Iran","adm1name":"Hormozgan","iso_a2":"IR"},"coordinates":[656311,665886]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Hamadan","adm0name":"Iran","adm1name":"Hamadan","iso_a2":"IR"},"coordinates":[634763,710864]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Tabriz","adm0name":"Iran","adm1name":"East Azarbaijan","iso_a2":"IR"},"coordinates":[628608,730369]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ludhiana","adm0name":"India","adm1name":"Punjab","iso_a2":"IN"},"coordinates":[710750,687959]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Kota","adm0name":"India","adm1name":"Rajasthan","iso_a2":"IN"},"coordinates":[710647,653906]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jodhpur","adm0name":"India","adm1name":"Rajasthan","iso_a2":"IN"},"coordinates":[702818,660493]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Shymkent","adm0name":"Kazakhstan","adm1name":"South Kazakhstan","iso_a2":"KZ"},"coordinates":[693319,755440]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Taraz","adm0name":"Kazakhstan","adm1name":"Zhambyl","iso_a2":"KZ"},"coordinates":[698236,758876]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Lucknow","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[724758,663830]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Saharanpur","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[715416,682273]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ranchi","adm0name":"India","adm1name":"Jharkhand","iso_a2":"IN"},"coordinates":[737021,643183]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Bhagalpur","adm0name":"India","adm1name":"Bihar","iso_a2":"IN"},"coordinates":[741610,654191]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Raipur","adm0name":"India","adm1name":"Chhattisgarh","iso_a2":"IN"},"coordinates":[726757,630534]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Jabalpur","adm0name":"India","adm1name":"Madhya Pradesh","iso_a2":"IN"},"coordinates":[722091,642028]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Indore","adm0name":"India","adm1name":"Madhya Pradesh","iso_a2":"IN"},"coordinates":[710730,639303]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Pondicherry","adm0name":"India","adm1name":"Puducherry","iso_a2":"IN"},"coordinates":[721749,575425]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Salem","adm0name":"India","adm1name":"Tamil Nadu","iso_a2":"IN"},"coordinates":[717160,573867]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Tiruchirappalli","adm0name":"India","adm1name":"Tamil Nadu","iso_a2":"IN"},"coordinates":[718577,568772]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Pointe-Noire","adm0name":"Congo (Brazzaville)","adm1name":"Kouilou","iso_a2":"CG"},"coordinates":[533000,476457]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Kankan","adm0name":"Guinea","adm1name":"Kankan","iso_a2":"GN"},"coordinates":[474138,566272]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Nzerekore","adm0name":"Guinea","adm1name":"Nzerekore","iso_a2":"GN"},"coordinates":[475472,550691]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Bouake","adm0name":"Ivory Coast","adm1name":"Vallée du Bandama","iso_a2":"CI"},"coordinates":[486027,550276]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"St.-Denis","adm0name":"France","adm1name":"La Réunion","iso_a2":"RE"},"coordinates":[654022,381021]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Rio Branco","adm0name":"Brazil","adm1name":"Acre","iso_a2":"BR"},"coordinates":[311666,445671]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"São Luís","adm0name":"Brazil","adm1name":"Maranhão","iso_a2":"BR"},"coordinates":[377034,489822]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Porto Velho","adm0name":"Brazil","adm1name":"Rondônia","iso_a2":"BR"},"coordinates":[322500,452878]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Alvorada","adm0name":"Brazil","adm1name":"Tocantins","iso_a2":"BR"},"coordinates":[363661,430839]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Corumba","adm0name":"Brazil","adm1name":"Mato Grosso do Sul","iso_a2":"BR"},"coordinates":[339861,392057]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Belo Horizonte","adm0name":"Brazil","adm1name":"Minas Gerais","iso_a2":"BR"},"coordinates":[378008,386743]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Montes Claros","adm0name":"Brazil","adm1name":"Minas Gerais","iso_a2":"BR"},"coordinates":[378166,405660]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Uberlandia","adm0name":"Brazil","adm1name":"Minas Gerais","iso_a2":"BR"},"coordinates":[365888,392745]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Colider","adm0name":"Brazil","adm1name":"Mato Grosso","iso_a2":"BR"},"coordinates":[345970,440630]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Alta Floresta","adm0name":"Brazil","adm1name":"Mato Grosso","iso_a2":"BR"},"coordinates":[344694,446065]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Cuiaba","adm0name":"Brazil","adm1name":"Mato Grosso","iso_a2":"BR"},"coordinates":[344203,412487]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Pelotas","adm0name":"Brazil","adm1name":"Rio Grande do Sul","iso_a2":"BR"},"coordinates":[354639,316616]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Caxias do Sul","adm0name":"Brazil","adm1name":"Rio Grande do Sul","iso_a2":"BR"},"coordinates":[357860,331842]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ponta Grossa","adm0name":"Brazil","adm1name":"Paraná","iso_a2":"BR"},"coordinates":[360666,356072]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Teresina","adm0name":"Brazil","adm1name":"Piauí","iso_a2":"BR"},"coordinates":[381161,474543]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Maceio","adm0name":"Brazil","adm1name":"Alagoas","iso_a2":"BR"},"coordinates":[400745,447735]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Vitoria da Conquista","adm0name":"Brazil","adm1name":"Bahia","iso_a2":"BR"},"coordinates":[386555,416739]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Barreiras","adm0name":"Brazil","adm1name":"Bahia","iso_a2":"BR"},"coordinates":[375000,432794]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Vila Velha","adm0name":"Brazil","adm1name":"Espírito Santo","iso_a2":"BR"},"coordinates":[388005,384050]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Natal","adm0name":"Brazil","adm1name":"Rio Grande do Norte","iso_a2":"BR"},"coordinates":[402105,470485]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Thompson","adm0name":"Canada","adm1name":"Manitoba","iso_a2":"CA"},"coordinates":[228148,835005]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Brandon","adm0name":"Canada","adm1name":"Manitoba","iso_a2":"CA"},"coordinates":[222361,799952]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Fort Smith","adm0name":"Canada","adm1name":"Alberta","iso_a2":"CA"},"coordinates":[189212,860185]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Fort McMurray","adm0name":"Canada","adm1name":"Alberta","iso_a2":"CA"},"coordinates":[190601,840831]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Peace River","adm0name":"Canada","adm1name":"Alberta","iso_a2":"CA"},"coordinates":[174213,837869]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Fort St. John","adm0name":"Canada","adm1name":"British Columbia","iso_a2":"CA"},"coordinates":[164352,837967]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Iqaluit","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[309722,882404]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Cambridge Bay","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[208240,914197]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kugluktuk","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[180207,906387]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Chesterfield Inlet","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[248055,879962]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Arviat","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[238726,866752]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Taloyoak","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[240185,916664]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Igloolik","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[272796,915024]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Dawson City","adm0name":"Canada","adm1name":"Yukon","iso_a2":"CA"},"coordinates":[112731,884277]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Timmins","adm0name":"Canada","adm1name":"Ontario","iso_a2":"CA"},"coordinates":[274074,791855]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"North Bay","adm0name":"Canada","adm1name":"Ontario","iso_a2":"CA"},"coordinates":[279305,779019]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kuujjuarapik","adm0name":"Canada","adm1name":"Québec","iso_a2":"CA"},"coordinates":[283983,832229]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Kuujjuaq","adm0name":"Canada","adm1name":"Québec","iso_a2":"CA"},"coordinates":[310000,848928]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Sydney","adm0name":"Canada","adm1name":"Nova Scotia","iso_a2":"CA"},"coordinates":[332833,777634]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Labrador City","adm0name":"Canada","adm1name":"Newfoundland and Labrador","iso_a2":"CA"},"coordinates":[314122,818366]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Ebolowa","adm0name":"Cameroon","adm1name":"Sud","iso_a2":"CM"},"coordinates":[530972,521898]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Bambari","adm0name":"Central African Republic","adm1name":"Ouaka","iso_a2":"CF"},"coordinates":[557408,538854]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Venice","adm0name":"Italy","adm1name":"Veneto","iso_a2":"IT"},"coordinates":[534263,773916]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"El Calafate","adm0name":"Argentina","adm1name":"Santa Cruz","iso_a2":"AR"},"coordinates":[299166,206520]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"San Juan","adm0name":"Argentina","adm1name":"San Juan","iso_a2":"AR"},"coordinates":[309667,317800]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Rawson","adm0name":"Argentina","adm1name":"Chubut","iso_a2":"AR"},"coordinates":[319167,248188]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Neuquen","adm0name":"Argentina","adm1name":"Neuquén","iso_a2":"AR"},"coordinates":[310944,273959]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Trinidad","adm0name":"Bolivia","adm1name":"El Beni","iso_a2":"BO"},"coordinates":[319722,416838]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Santa Rosa","adm0name":"Argentina","adm1name":"La Pampa","iso_a2":"AR"},"coordinates":[321389,287763]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"San Carlos de Bariloche","adm0name":"Argentina","adm1name":"Río Negro","iso_a2":"AR"},"coordinates":[301944,260926]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Salta","adm0name":"Argentina","adm1name":"Salta","iso_a2":"AR"},"coordinates":[318286,357889]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Tucumán","adm0name":"Argentina","adm1name":"Tucumán","iso_a2":"AR"},"coordinates":[318837,345858]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Formosa","adm0name":"Argentina","adm1name":"Formosa","iso_a2":"AR"},"coordinates":[338380,349657]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Santa Fe","adm0name":"Argentina","adm1name":"Santa Fe","iso_a2":"AR"},"coordinates":[331416,317363]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Rosario","adm0name":"Argentina","adm1name":"Santa Fe","iso_a2":"AR"},"coordinates":[331477,309511]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Campinas","adm0name":"Brazil","adm1name":"São Paulo","iso_a2":"BR"},"coordinates":[369161,369059]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Sorocaba","adm0name":"Brazil","adm1name":"São Paulo","iso_a2":"BR"},"coordinates":[368139,365552]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Ribeirao Preto","adm0name":"Brazil","adm1name":"São Paulo","iso_a2":"BR"},"coordinates":[367139,379296]},{"type":"Point","properties":{"scalerank":4,"labelrank":1,"name":"Petrolina","adm0name":"Brazil","adm1name":"Pernambuco","iso_a2":"BR"},"coordinates":[387472,449145]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Bamenda","adm0name":"Cameroon","adm1name":"Nord-Ouest","iso_a2":"CM"},"coordinates":[528194,540027]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Garoua","adm0name":"Cameroon","adm1name":"Nord","iso_a2":"CM"},"coordinates":[537194,559815]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Herat","adm0name":"Afghanistan","adm1name":"Hirat","iso_a2":"AF"},"coordinates":[672694,708103]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Mazar-e Sharif","adm0name":"Afghanistan","adm1name":"Balkh","iso_a2":"AF"},"coordinates":[686388,722144]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Battambang","adm0name":"Cambodia","adm1name":"Batdâmbâng","iso_a2":"KH"},"coordinates":[786666,582327]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Siem Reap","adm0name":"Cambodia","adm1name":"Siemréab","iso_a2":"KH"},"coordinates":[788471,583907]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Malanje","adm0name":"Angola","adm1name":"Malanje","iso_a2":"AO"},"coordinates":[545389,448198]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Benguela","adm0name":"Angola","adm1name":"Benguela","iso_a2":"AO"},"coordinates":[537242,430198]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Lubango","adm0name":"Angola","adm1name":"Huíla","iso_a2":"AO"},"coordinates":[537471,416383]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Namibe","adm0name":"Angola","adm1name":"Namibe","iso_a2":"AO"},"coordinates":[533778,414725]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Tarija","adm0name":"Bolivia","adm1name":"Tarija","iso_a2":"BO"},"coordinates":[320138,377243]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Bridgetown","adm0name":"Barbados","adm1name":"Saint Michael","iso_a2":"BB"},"coordinates":[334399,582339]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Annaba","adm0name":"Algeria","adm1name":"Annaba","iso_a2":"DZ"},"coordinates":[521555,723448]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Parakou","adm0name":"Benin","adm1name":"Borgou","iso_a2":"BJ"},"coordinates":[507278,560052]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Porto-Novo","adm0name":"Benin","adm1name":"Ouémé","iso_a2":"BJ"},"coordinates":[507268,543127]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Constantine","adm0name":"Algeria","adm1name":"Constantine","iso_a2":"DZ"},"coordinates":[518332,720130]},{"type":"Point","properties":{"scalerank":4,"labelrank":6,"name":"Brest","adm0name":"Belarus","adm1name":"Brest","iso_a2":"BY"},"coordinates":[565833,813381]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Khulna","adm0name":"Bangladesh","adm1name":"Khulna","iso_a2":"BD"},"coordinates":[748772,640043]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Francistown","adm0name":"Botswana","adm1name":"Central","iso_a2":"BW"},"coordinates":[576388,379296]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Mahalapye","adm0name":"Botswana","adm1name":"Central","iso_a2":"BW"},"coordinates":[574500,367862]},{"type":"Point","properties":{"scalerank":4,"labelrank":7,"name":"Serowe","adm0name":"Botswana","adm1name":"Central","iso_a2":"BW"},"coordinates":[574194,372068]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Katherine","adm0name":"Australia","adm1name":"Northern Territory","iso_a2":"AU"},"coordinates":[867406,419010]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Busselton","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[820412,305321]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Mandurah","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[821519,312034]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Broome","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[839530,398304]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Kalgoorlie","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[837388,322627]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Albany","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[827476,297261]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Port Hedland","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[829460,384389]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Karratha","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[824638,381901]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Geraldton","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[818332,334291]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Griffith","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[905665,301567]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Orange","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[914166,307551]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Dubbo","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[912769,313595]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Armidale","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[921297,323948]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Broken Hill","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[892869,315431]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Port Lincoln","adm0name":"Australia","adm1name":"South Australia","iso_a2":"AU"},"coordinates":[877407,298942]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Whyalla","adm0name":"Australia","adm1name":"South Australia","iso_a2":"AU"},"coordinates":[882114,309062]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Portland","adm0name":"Australia","adm1name":"Victoria","iso_a2":"AU"},"coordinates":[893305,277573]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Bendigo","adm0name":"Australia","adm1name":"Victoria","iso_a2":"AU"},"coordinates":[900777,286934]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Wangaratta","adm0name":"Australia","adm1name":"Victoria","iso_a2":"AU"},"coordinates":[906388,289304]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Windorah","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[896250,354039]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Mount Isa","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[887471,381939]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Rockhampton","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[918110,366299]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Cairns","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[904897,404666]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Gold Coast","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[926245,338350]},{"type":"Point","properties":{"scalerank":4,"labelrank":3,"name":"Devonport","adm0name":"Australia","adm1name":"Tasmania","iso_a2":"AU"},"coordinates":[906475,260673]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Bobo Dioulasso","adm0name":"Burkina Faso","adm1name":"Houet","iso_a2":"BF"},"coordinates":[488083,570952]},{"type":"Point","properties":{"scalerank":4,"labelrank":2,"name":"Rajshahi","adm0name":"Bangladesh","adm1name":"Rajshahi","iso_a2":"BD"},"coordinates":[746118,649137]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Mandalay","adm0name":"Myanmar","adm1name":"Mandalay","iso_a2":"MM"},"coordinates":[766897,634889]},{"type":"Point","properties":{"scalerank":4,"labelrank":5,"name":"Sittwe","adm0name":"Myanmar","adm1name":"Rakhine","iso_a2":"MM"},"coordinates":[758000,624035]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Bujumbura","adm0name":"Burundi","adm1name":"Bujumbura Mairie","iso_a2":"BI"},"coordinates":[581555,484715]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Pago Pago","adm0name":"American Samoa","iso_a2":"AS"},"coordinates":[25815,420136]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Kingstown","adm0name":"Saint Vincent and the Grenadines","iso_a2":"VC"},"coordinates":[329966,582614]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Castries","adm0name":"Saint Lucia","iso_a2":"LC"},"coordinates":[330555,587671]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Basseterre","adm0name":"Saint Kitts and Nevis","iso_a2":"KN"},"coordinates":[325786,607222]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Las Palmas","adm0name":"Spain","iso_a2":"ES"},"coordinates":[457138,671194]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Berbera","adm0name":"Somaliland","iso_a2":"-99"},"coordinates":[625045,566542]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Port Louis","adm0name":"Mauritius","iso_a2":"MU"},"coordinates":[659722,385241]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Gaza","adm0name":"Palestine","iso_a2":"PS"},"coordinates":[595680,691515]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Saint George's","adm0name":"Grenada","iso_a2":"GD"},"coordinates":[328495,576122]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Papeete","adm0name":"French Polynesia","iso_a2":"PF"},"coordinates":[84537,400842]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Manama","adm0name":"Bahrain","iso_a2":"BH"},"coordinates":[640508,660152]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Freeport","adm0name":"The Bahamas","iso_a2":"BS"},"coordinates":[281389,661912]},{"type":"Point","properties":{"scalerank":4,"labelrank":0,"name":"Saint John's","adm0name":"Antigua and Barbuda","iso_a2":"AG"},"coordinates":[328194,606132]},{"type":"Point","properties":{"scalerank":4,"labelrank":8,"name":"Taichung","adm0name":"Taiwan","adm1name":"Taichung City","iso_a2":"TW"},"coordinates":[835226,647805]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Kozhikode","adm0name":"India","adm1name":"Kerala","iso_a2":"IN"},"coordinates":[710466,571381]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Bhubaneshwar","adm0name":"India","adm1name":"Orissa","iso_a2":"IN"},"coordinates":[738403,624820]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Jamshedpur","adm0name":"India","adm1name":"Jharkhand","iso_a2":"IN"},"coordinates":[739431,639733]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Montevideo","adm0name":"Uruguay","adm1name":"Montevideo","iso_a2":"UY"},"coordinates":[343963,298213]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Helena","adm0name":"United States of America","adm1name":"Montana","iso_a2":"US"},"coordinates":[188791,780754]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Bismarck","adm0name":"United States of America","adm1name":"North Dakota","iso_a2":"US"},"coordinates":[220046,782031]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Boise","adm0name":"United States of America","adm1name":"Idaho","iso_a2":"US"},"coordinates":[177146,763074]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"San Jose","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[161523,725711]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Sacramento","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[162578,733265]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Las Vegas","adm0name":"United States of America","adm1name":"Nevada","iso_a2":"US"},"coordinates":[179939,719253]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Santa Fe","adm0name":"United States of America","adm1name":"New Mexico","iso_a2":"US"},"coordinates":[205729,716142]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Portland","adm0name":"United States of America","adm1name":"Oregon","iso_a2":"US"},"coordinates":[159217,774410]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Salt Lake City","adm0name":"United States of America","adm1name":"Utah","iso_a2":"US"},"coordinates":[189078,746298]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Cheyenne","adm0name":"United States of America","adm1name":"Wyoming","iso_a2":"US"},"coordinates":[208834,748449]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Des Moines","adm0name":"United States of America","adm1name":"Iowa","iso_a2":"US"},"coordinates":[239944,751055]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Omaha","adm0name":"United States of America","adm1name":"Nebraska","iso_a2":"US"},"coordinates":[233305,749041]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Oklahoma City","adm0name":"United States of America","adm1name":"Oklahoma","iso_a2":"US"},"coordinates":[229109,714869]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Pierre","adm0name":"United States of America","adm1name":"South Dakota","iso_a2":"US"},"coordinates":[221248,767575]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"San Antonio","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[226363,679425]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"San Cristobal","adm0name":"Venezuela","adm1name":"Táchira","iso_a2":"VE"},"coordinates":[299305,550750]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Valencia","adm0name":"Venezuela","adm1name":"Carabobo","iso_a2":"VE"},"coordinates":[311161,565336]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Jackson","adm0name":"United States of America","adm1name":"Mississippi","iso_a2":"US"},"coordinates":[249486,696070]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Raleigh","adm0name":"United States of America","adm1name":"North Carolina","iso_a2":"US"},"coordinates":[281542,716923]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Cleveland","adm0name":"United States of America","adm1name":"Ohio","iso_a2":"US"},"coordinates":[273064,750415]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Cincinnati","adm0name":"United States of America","adm1name":"Ohio","iso_a2":"US"},"coordinates":[265392,736741]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Nashville","adm0name":"United States of America","adm1name":"Tennessee","iso_a2":"US"},"coordinates":[258939,719016]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Memphis","adm0name":"United States of America","adm1name":"Tennessee","iso_a2":"US"},"coordinates":[249994,712796]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Norfolk","adm0name":"United States of America","adm1name":"Virginia","iso_a2":"US"},"coordinates":[288111,723033]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Milwaukee","adm0name":"United States of America","adm1name":"Wisconsin","iso_a2":"US"},"coordinates":[255773,759792]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Buffalo","adm0name":"United States of America","adm1name":"New York","iso_a2":"US"},"coordinates":[280884,758769]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Pittsburgh","adm0name":"United States of America","adm1name":"Pennsylvania","iso_a2":"US"},"coordinates":[277772,744254]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Ciudad Guayana","adm0name":"Venezuela","adm1name":"Bolívar","iso_a2":"VE"},"coordinates":[326055,554305]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Lome","adm0name":"Togo","adm1name":"Maritime","iso_a2":"TG"},"coordinates":[503390,541057]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Tunis","adm0name":"Tunisia","adm1name":"Tunis","iso_a2":"TN"},"coordinates":[528276,722753]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Kodiak","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[76648,847091]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Cold Bay","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[48014,831747]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Bethel","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[50678,864884]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Point Hope","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[36644,909640]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Barrow","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[64476,927075]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Nome","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[40538,886881]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Valdez","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[93477,866914]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Juneau","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[126611,850197]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Fairbanks","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[89693,888841]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Prudhoe Bay","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[87030,921160]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Sevastapol","adm0name":"Ukraine","adm1name":"Crimea","iso_a2":"UA"},"coordinates":[592958,768948]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Abu Dhabi","adm0name":"United Arab Emirates","adm1name":"Abu Dhabi","iso_a2":"AE"},"coordinates":[651018,649669]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Ashgabat","adm0name":"Turkmenistan","adm1name":"Ahal","iso_a2":"TM"},"coordinates":[662175,729550]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Samarqand","adm0name":"Uzbekistan","adm1name":"Samarkand","iso_a2":"UZ"},"coordinates":[685958,739740]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Lusaka","adm0name":"Zambia","adm1name":"Lusaka","iso_a2":"ZM"},"coordinates":[578559,413393]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Harare","adm0name":"Zimbabwe","adm1name":"Harare","iso_a2":"ZW"},"coordinates":[586230,399168]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Bulawayo","adm0name":"Zimbabwe","adm1name":"Bulawayo","iso_a2":"ZW"},"coordinates":[579388,385221]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Dili","adm0name":"East Timor","adm1name":"Dili","iso_a2":"TL"},"coordinates":[848831,454008]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Port Vila","adm0name":"Vanuatu","adm1name":"Shefa","iso_a2":"VU"},"coordinates":[967545,399657]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Tegucigalpa","adm0name":"Honduras","adm1name":"Francisco Morazán","iso_a2":"HN"},"coordinates":[257724,588276]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Georgetown","adm0name":"Guyana","adm1name":"East Berbice-Corentyne","iso_a2":"GY"},"coordinates":[338424,545015]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Reykjavík","adm0name":"Iceland","adm1name":"Suðurnes","iso_a2":"IS"},"coordinates":[439028,884771]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Port-au-Prince","adm0name":"Haiti","adm1name":"Ouest","iso_a2":"HT"},"coordinates":[299061,614574]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Glasgow","adm0name":"United Kingdom","adm1name":"Glasgow","iso_a2":"GB"},"coordinates":[488187,835753]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Kampala","adm0name":"Uganda","adm1name":"Kampala","iso_a2":"UG"},"coordinates":[590503,506605]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Aden","adm0name":"Yemen","adm1name":"`Adan","iso_a2":"YE"},"coordinates":[625025,580430]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Paramaribo","adm0name":"Suriname","adm1name":"Paramaribo","iso_a2":"SR"},"coordinates":[346758,539286]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Seville","adm0name":"Spain","adm1name":"Andalucía","iso_a2":"ES"},"coordinates":[483389,726322]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Zinder","adm0name":"Niger","adm1name":"Zinder","iso_a2":"NE"},"coordinates":[524953,586474]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Niamey","adm0name":"Niger","adm1name":"Niamey","iso_a2":"NE"},"coordinates":[505874,584808]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Port Sudan","adm0name":"Sudan","adm1name":"Red Sea","iso_a2":"SD"},"coordinates":[603378,620930]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Dushanbe","adm0name":"Tajikistan","adm1name":"Tadzhikistan Territories","iso_a2":"TJ"},"coordinates":[691038,733164]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Cusco","adm0name":"Peru","adm1name":"Cusco","iso_a2":"PE"},"coordinates":[300077,424589]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Tacna","adm0name":"Peru","adm1name":"Tacna","iso_a2":"PE"},"coordinates":[304861,398077]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Trujillo","adm0name":"Peru","adm1name":"La Libertad","iso_a2":"PE"},"coordinates":[280499,456610]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Ica","adm0name":"Peru","adm1name":"Ica","iso_a2":"PE"},"coordinates":[289651,421372]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Asuncion","adm0name":"Paraguay","adm1name":"Asunción","iso_a2":"PY"},"coordinates":[339879,354861]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Managua","adm0name":"Nicaragua","adm1name":"Managua","iso_a2":"NI"},"coordinates":[260359,576729]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Freetown","adm0name":"Sierra Leone","adm1name":"Western","iso_a2":"SL"},"coordinates":[463233,554909]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Agadez","adm0name":"Niger","adm1name":"Agadez","iso_a2":"NE"},"coordinates":[522174,605409]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Niyala","adm0name":"Sudan","adm1name":"South Darfur","iso_a2":"SD"},"coordinates":[569138,576166]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Wau","adm0name":"South Sudan","adm1name":"West Bahr-al-Ghazal","iso_a2":"SS"},"coordinates":[577750,550335]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Dongola","adm0name":"Sudan","adm1name":"Northern","iso_a2":"SD"},"coordinates":[584676,618269]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Kassala","adm0name":"Sudan","adm1name":"Kassala","iso_a2":"SD"},"coordinates":[601083,596309]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Tromsø","adm0name":"Norway","adm1name":"Troms","iso_a2":"NO"},"coordinates":[552755,917266]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Trondheim","adm0name":"Norway","adm1name":"Sør-Trøndelag","iso_a2":"NO"},"coordinates":[528934,880426]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Bergen","adm0name":"Norway","adm1name":"Hordaland","iso_a2":"NO"},"coordinates":[514790,862501]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Islamabad","adm0name":"Pakistan","adm1name":"F.C.T.","iso_a2":"PK"},"coordinates":[703235,704383]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Multan","adm0name":"Pakistan","adm1name":"Punjab","iso_a2":"PK"},"coordinates":[698480,683647]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Hyderabad","adm0name":"Pakistan","adm1name":"Sind","iso_a2":"PK"},"coordinates":[689924,655091]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Peshawar","adm0name":"Pakistan","adm1name":"N.W.F.P.","iso_a2":"PK"},"coordinates":[698702,706190]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Kathmandu","adm0name":"Nepal","adm1name":"Bhaktapur","iso_a2":"NP"},"coordinates":[736984,668935]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Nacala","adm0name":"Mozambique","adm1name":"Nampula","iso_a2":"MZ"},"coordinates":[613096,418702]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Bloemfontein","adm0name":"South Africa","adm1name":"Orange Free State","iso_a2":"ZA"},"coordinates":[572860,332197]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Pretoria","adm0name":"South Africa","adm1name":"Gauteng","iso_a2":"ZA"},"coordinates":[578409,352429]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Port Moresby","adm0name":"Papua New Guinea","adm1name":"Central","iso_a2":"PG"},"coordinates":[908867,448644]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Honiara","adm0name":"Solomon Islands","adm1name":"Guadalcanal","iso_a2":"SB"},"coordinates":[944304,448802]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Panama City","adm0name":"Panama","adm1name":"Panama","iso_a2":"PA"},"coordinates":[279069,557859]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Fez","adm0name":"Morocco","adm1name":"Fès - Boulemane","iso_a2":"MA"},"coordinates":[486104,706483]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Rabat","adm0name":"Morocco","adm1name":"Rabat - Salé - Zemmour - Zaer","iso_a2":"MA"},"coordinates":[481009,706298]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Marrakesh","adm0name":"Morocco","adm1name":"Marrakech - Tensift - Al Haouz","iso_a2":"MA"},"coordinates":[477772,692119]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Chisinau","adm0name":"Moldova","adm1name":"Chisinau","iso_a2":"MD"},"coordinates":[580159,783196]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Beira","adm0name":"Mozambique","adm1name":"Sofala","iso_a2":"MZ"},"coordinates":[596860,387294]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Port Elizabeth","adm0name":"South Africa","adm1name":"Eastern Cape","iso_a2":"ZA"},"coordinates":[571105,303474]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Maputo","adm0name":"Mozambique","adm1name":"Maputo","iso_a2":"MZ"},"coordinates":[590520,350958]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Tomsk","adm0name":"Russia","adm1name":"Tomsk","iso_a2":"RU"},"coordinates":[736041,839419]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Anadyr","adm0name":"Russia","adm1name":"Chukchi Autonomous Okrug","iso_a2":"RU"},"coordinates":[992986,888248]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Murmansk","adm0name":"Russia","adm1name":"Murmansk","iso_a2":"RU"},"coordinates":[591944,913327]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Archangel","adm0name":"Russia","adm1name":"Arkhangel'sk","iso_a2":"RU"},"coordinates":[612625,887289]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Nizhny Novgorod","adm0name":"Russia","adm1name":"Nizhegorod","iso_a2":"RU"},"coordinates":[622217,838471]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Volgograd","adm0name":"Russia","adm1name":"Volgograd","iso_a2":"RU"},"coordinates":[623605,793308]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Ufa","adm0name":"Russia","adm1name":"Bashkortostan","iso_a2":"RU"},"coordinates":[655660,829329]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Yekaterinburg","adm0name":"Russia","adm1name":"Sverdlovsk","iso_a2":"RU"},"coordinates":[668327,841534]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Samara","adm0name":"Russia","adm1name":"Samara","iso_a2":"RU"},"coordinates":[639303,819880]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Kazan","adm0name":"Russia","adm1name":"Tatarstan","iso_a2":"RU"},"coordinates":[636456,835017]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Surgut","adm0name":"Russia","adm1name":"Khanty-Mansiy","iso_a2":"RU"},"coordinates":[703958,867649]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Barnaul","adm0name":"Russia","adm1name":"Altay","iso_a2":"RU"},"coordinates":[732624,820816]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Novosibirsk","adm0name":"Russia","adm1name":"Novosibirsk","iso_a2":"RU"},"coordinates":[730438,830751]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Mogadishu","adm0name":"Somalia","adm1name":"Banaadir","iso_a2":"SO"},"coordinates":[626013,516973]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Muscat","adm0name":"Oman","adm1name":"Muscat","iso_a2":"OM"},"coordinates":[662758,644613]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Colombo","adm0name":"Sri Lanka","adm1name":"Colombo","iso_a2":"LK"},"coordinates":[721827,545785]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Cebu","adm0name":"Philippines","adm1name":"Cebu","iso_a2":"PH"},"coordinates":[844161,565868]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Iloilo","adm0name":"Philippines","adm1name":"Iloilo","iso_a2":"PH"},"coordinates":[840402,568139]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Davao","adm0name":"Philippines","adm1name":"Davao Del Sur","iso_a2":"PH"},"coordinates":[848966,546852]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Bratsk","adm0name":"Russia","adm1name":"Irkutsk","iso_a2":"RU"},"coordinates":[782263,837417]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Irkutsk","adm0name":"Russia","adm1name":"Irkutsk","iso_a2":"RU"},"coordinates":[789569,814685]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Krasnoyarsk","adm0name":"Russia","adm1name":"Krasnoyarsk","iso_a2":"RU"},"coordinates":[757955,836581]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Dickson","adm0name":"Russia","adm1name":"Taymyr","iso_a2":"RU"},"coordinates":[723736,940205]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Chita","adm0name":"Russia","adm1name":"Chita","iso_a2":"RU"},"coordinates":[815180,813115]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Vladivostok","adm0name":"Russia","adm1name":"Primor'ye","iso_a2":"RU"},"coordinates":[866416,760239]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Nizhneyansk","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[877962,927920]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Yakutsk","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[860374,872240]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Tiksi","adm0name":"Russia","adm1name":"Sakha (Yakutia)","iso_a2":"RU"},"coordinates":[857874,929067]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Magadan","adm0name":"Russia","adm1name":"Maga Buryatdan","iso_a2":"RU"},"coordinates":[918916,857666]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Tijuana","adm0name":"Mexico","adm1name":"Baja California","iso_a2":"MX"},"coordinates":[174772,697273]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Chihuahua","adm0name":"Mexico","adm1name":"Chihuahua","iso_a2":"MX"},"coordinates":[205314,674434]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Mazatlan","adm0name":"Mexico","adm1name":"Sinaloa","iso_a2":"MX"},"coordinates":[204388,642289]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Tampico","adm0name":"Mexico","adm1name":"Tamaulipas","iso_a2":"MX"},"coordinates":[228139,636832]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Acapulco","adm0name":"Mexico","adm1name":"Guerrero","iso_a2":"MX"},"coordinates":[222456,604544]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Veracruz","adm0name":"Mexico","adm1name":"Veracruz","iso_a2":"MX"},"coordinates":[232889,618332]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Tuxtla Gutierrez","adm0name":"Mexico","adm1name":"Chiapas","iso_a2":"MX"},"coordinates":[241250,603952]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Cancun","adm0name":"Mexico","adm1name":"Quintana Roo","iso_a2":"MX"},"coordinates":[258805,630138]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Merida","adm0name":"Mexico","adm1name":"Yucatán","iso_a2":"MX"},"coordinates":[251059,628944]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Enugu","adm0name":"Nigeria","adm1name":"Enugu","iso_a2":"NG"},"coordinates":[520833,542930]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Sokoto","adm0name":"Nigeria","adm1name":"Sokoto","iso_a2":"NG"},"coordinates":[514555,582090]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Perm","adm0name":"Russia","adm1name":"Perm'","iso_a2":"RU"},"coordinates":[656244,848347]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Erdenet","adm0name":"Mongolia","adm1name":"Orhon","iso_a2":"MN"},"coordinates":[789217,795331]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Ulaanbaatar","adm0name":"Mongolia","adm1name":"Ulaanbaatar","iso_a2":"MN"},"coordinates":[796984,788609]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Wellington","adm0name":"New Zealand","adm1name":"Manawatu-Wanganui","iso_a2":"NZ"},"coordinates":[985509,260037]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Mbeya","adm0name":"Tanzania","adm1name":"Mbeya","iso_a2":"TZ"},"coordinates":[592861,452049]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Windhoek","adm0name":"Namibia","adm1name":"Khomas","iso_a2":"NA"},"coordinates":[547454,371002]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Grootfontein","adm0name":"Namibia","adm1name":"Otjozondjupa","iso_a2":"NA"},"coordinates":[550323,388796]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Zanzibar","adm0name":"Tanzania","adm1name":"Zanzibar West","iso_a2":"TZ"},"coordinates":[608889,468223]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Christchurch","adm0name":"New Zealand","adm1name":"Canterbury","iso_a2":"NZ"},"coordinates":[979527,246796]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Valencia","adm0name":"Spain","adm1name":"Comunidad Valenciana","iso_a2":"ES"},"coordinates":[498883,738656]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Palana","adm0name":"Russia","adm1name":"Kamchatka","iso_a2":"RU"},"coordinates":[944305,854757]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Petropavlovsk Kamchatskiy","adm0name":"Russia","adm1name":"Kamchatka","iso_a2":"RU"},"coordinates":[940619,819080]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Abuja","adm0name":"Nigeria","adm1name":"Federal Capital Territory","iso_a2":"NG"},"coordinates":[520920,558542]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Padang","adm0name":"Indonesia","adm1name":"Sumatera Barat","iso_a2":"ID"},"coordinates":[778771,499041]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Bissau","adm0name":"Guinea Bissau","adm1name":"Bissau","iso_a2":"GW"},"coordinates":[456670,575011]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Palermo","adm0name":"Italy","adm1name":"Sicily","iso_a2":"IT"},"coordinates":[537078,730599]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Amman","adm0name":"Jordan","adm1name":"Amman","iso_a2":"JO"},"coordinates":[599808,694015]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Vilnius","adm0name":"Lithuania","adm1name":"Vilniaus","iso_a2":"LT"},"coordinates":[570324,828686]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Riga","adm0name":"Latvia","adm1name":"Riga","iso_a2":"LV"},"coordinates":[566943,842115]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Bishkek","adm0name":"Kyrgyzstan","adm1name":"Bishkek","iso_a2":"KG"},"coordinates":[707175,758728]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Jiayuguan","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[773055,740629]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Xining","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[782688,721682]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Guilin","adm0name":"China","adm1name":"Guangxi","iso_a2":"CN"},"coordinates":[806327,654499]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Huainan","adm0name":"China","adm1name":"Anhui","iso_a2":"CN"},"coordinates":[824938,698043]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Shantou","adm0name":"China","adm1name":"Guangdong","iso_a2":"CN"},"coordinates":[824077,643183]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Tarakan","adm0name":"Indonesia","adm1name":"Kalimantan Timur","iso_a2":"ID"},"coordinates":[826757,524268]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Mombasa","adm0name":"Kenya","adm1name":"Coast","iso_a2":"KE"},"coordinates":[610243,480793]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Maseru","adm0name":"Lesotho","adm1name":"Maseru","iso_a2":"LS"},"coordinates":[576342,331032]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Antananarivo","adm0name":"Madagascar","adm1name":"Antananarivo","iso_a2":"MG"},"coordinates":[631985,392658]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Semarang","adm0name":"Indonesia","adm1name":"Jawa Tengah","iso_a2":"ID"},"coordinates":[806717,463455]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Palembang","adm0name":"Indonesia","adm1name":"Sumatera Selatan","iso_a2":"ID"},"coordinates":[790966,487074]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Bandjarmasin","adm0name":"Indonesia","adm1name":"Kalimantan Selatan","iso_a2":"ID"},"coordinates":[818277,484989]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Ujungpandang","adm0name":"Indonesia","adm1name":"Sulawesi Selatan","iso_a2":"ID"},"coordinates":[831749,474277]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Lyon","adm0name":"France","adm1name":"Rhône-Alpes","iso_a2":"FR"},"coordinates":[513411,775891]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Quito","adm0name":"Ecuador","adm1name":"Pichincha","iso_a2":"EC"},"coordinates":[281939,503455]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"San Jose","adm0name":"Costa Rica","adm1name":"San José","iso_a2":"CR"},"coordinates":[266428,563588]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"San Salvador","adm0name":"El Salvador","adm1name":"San Salvador","iso_a2":"SV"},"coordinates":[252208,585953]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Kingston","adm0name":"Jamaica","adm1name":"Kingston","iso_a2":"JM"},"coordinates":[286756,611221]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Cartagena","adm0name":"Colombia","adm1name":"Bolívar","iso_a2":"CO"},"coordinates":[290232,566341]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Mitu","adm0name":"Colombia","adm1name":"Vaupés","iso_a2":"CO"},"coordinates":[305073,511816]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Bumba","adm0name":"Congo (Kinshasa)","adm1name":"Équateur","iso_a2":"CD"},"coordinates":[562388,517692]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Ndjamena","adm0name":"Chad","adm1name":"Hadjer-Lamis","iso_a2":"TD"},"coordinates":[541797,576492]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Abeche","adm0name":"Chad","adm1name":"Ouaddaï","iso_a2":"TD"},"coordinates":[557860,586711]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Malabo","adm0name":"Equatorial Guinea","adm1name":"Bioko Norte","iso_a2":"GQ"},"coordinates":[524398,526934]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Luxor","adm0name":"Egypt","adm1name":"Qina","iso_a2":"EG"},"coordinates":[590694,656975]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Asmara","adm0name":"Eritrea","adm1name":"Anseba","iso_a2":"ER"},"coordinates":[608148,595558]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Zagreb","adm0name":"Croatia","adm1name":"Grad Zagreb","iso_a2":"HR"},"coordinates":[544444,776057]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Tallinn","adm0name":"Estonia","adm1name":"Harju","iso_a2":"EE"},"coordinates":[568688,856830]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Lhasa","adm0name":"China","adm1name":"Xizang","iso_a2":"CN"},"coordinates":[753055,680348]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Hami","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[759763,758443]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Hotan","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[722018,724513]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Kashgar","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[711027,738593]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Yinchuan","adm0name":"China","adm1name":"Ningxia Hui","iso_a2":"CN"},"coordinates":[795197,732631]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Pingxiang","adm0name":"China","adm1name":"Jiangxi","iso_a2":"CN"},"coordinates":[816244,668362]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Nagasaki","adm0name":"Japan","adm1name":"Nagasaki","iso_a2":"JP"},"coordinates":[860790,698831]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Qiqihar","adm0name":"China","adm1name":"Heilongjiang","iso_a2":"CN"},"coordinates":[844410,785222]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Kikwit","adm0name":"Congo (Kinshasa)","adm1name":"Bandundu","iso_a2":"CD"},"coordinates":[552361,474917]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Matadi","adm0name":"Congo (Kinshasa)","adm1name":"Bas-Congo","iso_a2":"CD"},"coordinates":[537360,470257]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Kolwezi","adm0name":"Congo (Kinshasa)","adm1name":"Katanga","iso_a2":"CD"},"coordinates":[570756,441226]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Lubumbashi","adm0name":"Congo (Kinshasa)","adm1name":"Katanga","iso_a2":"CD"},"coordinates":[576327,435531]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Lilongwe","adm0name":"Malawi","adm1name":"Lilongwe","iso_a2":"MW"},"coordinates":[593842,421873]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Guatemala","adm0name":"Guatemala","adm1name":"Guatemala","iso_a2":"GT"},"coordinates":[248530,591351]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Cayenne","adm0name":"France","adm1name":"Guinaa","iso_a2":"GF"},"coordinates":[354639,533942]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Libreville","adm0name":"Gabon","adm1name":"Estuaire","iso_a2":"GA"},"coordinates":[526271,507000]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Vishakhapatnam","adm0name":"India","adm1name":"Andhra Pradesh","iso_a2":"IN"},"coordinates":[731396,609769]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Suva","adm0name":"Fiji","adm1name":"Central","iso_a2":"FJ"},"coordinates":[995670,397289]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Port-Gentil","adm0name":"Gabon","adm1name":"Ogooué-Maritime","iso_a2":"GA"},"coordinates":[524389,500451]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Timbuktu","adm0name":"Mali","adm1name":"Timbuktu","iso_a2":"ML"},"coordinates":[491620,604050]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Punta Arenas","adm0name":"Chile","adm1name":"Magallanes y Antártica Chilena","iso_a2":"CL"},"coordinates":[302944,189744]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Iquique","adm0name":"Chile","adm1name":"Tarapacá","iso_a2":"CL"},"coordinates":[305194,384747]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Antofagasta","adm0name":"Chile","adm1name":"Antofagasta","iso_a2":"CL"},"coordinates":[304444,364604]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Valparaiso","adm0name":"Chile","adm1name":"Valparaíso","iso_a2":"CL"},"coordinates":[301047,308939]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Valdivia","adm0name":"Chile","adm1name":"Los Ríos","iso_a2":"CL"},"coordinates":[296541,268953]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Concepcion","adm0name":"Chile","adm1name":"Bío-Bío","iso_a2":"CL"},"coordinates":[297083,286520]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Puerto Montt","adm0name":"Chile","adm1name":"Los Lagos","iso_a2":"CL"},"coordinates":[297416,259030]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Nuuk","adm0name":"Greenland","adm1name":"Kommuneqarfik Sermersooq","iso_a2":"GL"},"coordinates":[356298,885057]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Nouakchott","adm0name":"Mauritania","adm1name":"Nouakchott","iso_a2":"MR"},"coordinates":[455623,611869]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Bamako","adm0name":"Mali","adm1name":"Bamako","iso_a2":"ML"},"coordinates":[477771,579673]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Atar","adm0name":"Mauritania","adm1name":"Adrar","iso_a2":"MR"},"coordinates":[463750,626267]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Djenne","adm0name":"Mali","adm1name":"Mopti","iso_a2":"ML"},"coordinates":[487360,587067]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Sabha","adm0name":"Libya","adm1name":"Sabha","iso_a2":"LY"},"coordinates":[540092,664874]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Banghazi","adm0name":"Libya","adm1name":"Benghazi","iso_a2":"LY"},"coordinates":[555735,695002]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Thessaloniki","adm0name":"Greece","adm1name":"Kentriki Makedonia","iso_a2":"GR"},"coordinates":[563564,745831]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Beirut","adm0name":"Lebanon","adm1name":"Beirut","iso_a2":"LB"},"coordinates":[598632,705401]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Tbilisi","adm0name":"Georgia","adm1name":"Tbilisi","iso_a2":"GE"},"coordinates":[624412,751926]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Gonder","adm0name":"Ethiopia","adm1name":"Amhara","iso_a2":"ET"},"coordinates":[604055,579425]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Astana","adm0name":"Kazakhstan","adm1name":"Aqmola","iso_a2":"KZ"},"coordinates":[698409,807937]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Qaraghandy","adm0name":"Kazakhstan","adm1name":"Qaraghandy","iso_a2":"KZ"},"coordinates":[703096,800258]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Almaty","adm0name":"Kazakhstan","adm1name":"Almaty","iso_a2":"KZ"},"coordinates":[713646,761406]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Isfahan","adm0name":"Iran","adm1name":"Esfahan","iso_a2":"IR"},"coordinates":[643606,698458]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Shiraz","adm0name":"Iran","adm1name":"Fars","iso_a2":"IR"},"coordinates":[646022,680270]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Amritsar","adm0name":"India","adm1name":"Punjab","iso_a2":"IN"},"coordinates":[707966,692178]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Varanasi","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[730549,654795]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Asansol","adm0name":"India","adm1name":"West Bengal","iso_a2":"IN"},"coordinates":[741614,645039]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Bhilai","adm0name":"India","adm1name":"Chhattisgarh","iso_a2":"IN"},"coordinates":[726197,630426]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Bhopal","adm0name":"India","adm1name":"Madhya Pradesh","iso_a2":"IN"},"coordinates":[715022,642472]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Madurai","adm0name":"India","adm1name":"Tamil Nadu","iso_a2":"IN"},"coordinates":[716994,563499]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Coimbatore","adm0name":"India","adm1name":"Tamil Nadu","iso_a2":"IN"},"coordinates":[713744,569897]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Vientiane","adm0name":"Laos","adm1name":"Vientiane [prefecture]","iso_a2":"LA"},"coordinates":[784999,611160]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Brazzaville","adm0name":"Congo (Brazzaville)","adm1name":"Pool","iso_a2":"CG"},"coordinates":[542451,479495]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Conakry","adm0name":"Guinea","adm1name":"Conakry","iso_a2":"GN"},"coordinates":[461993,561198]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Yamoussoukro","adm0name":"Ivory Coast","adm1name":"Lacs","iso_a2":"CI"},"coordinates":[485346,545112]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Cruzeiro do Sul","adm0name":"Brazil","adm1name":"Acre","iso_a2":"BR"},"coordinates":[298138,459513]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Leticia","adm0name":"Colombia","adm1name":"Amazonas","iso_a2":"CO"},"coordinates":[305679,479827]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Manaus","adm0name":"Brazil","adm1name":"Amazonas","iso_a2":"BR"},"coordinates":[333328,486363]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Caxias","adm0name":"Brazil","adm1name":"Maranhão","iso_a2":"BR"},"coordinates":[379583,476084]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Santarem","adm0name":"Brazil","adm1name":"Pará","iso_a2":"BR"},"coordinates":[348055,490302]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Maraba","adm0name":"Brazil","adm1name":"Pará","iso_a2":"BR"},"coordinates":[363566,473022]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Vilhena","adm0name":"Brazil","adm1name":"Rondônia","iso_a2":"BR"},"coordinates":[333009,429378]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Ji-Parana","adm0name":"Brazil","adm1name":"Rondônia","iso_a2":"BR"},"coordinates":[327870,440536]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Campo Grande","adm0name":"Brazil","adm1name":"Mato Grosso do Sul","iso_a2":"BR"},"coordinates":[348282,383573]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Florianopolis","adm0name":"Brazil","adm1name":"Santa Catarina","iso_a2":"BR"},"coordinates":[365216,341333]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Feira de Santana","adm0name":"Brazil","adm1name":"Bahia","iso_a2":"BR"},"coordinates":[391750,432142]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Winnipeg","adm0name":"Canada","adm1name":"Manitoba","iso_a2":"CA"},"coordinates":[230094,800247]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Churchill","adm0name":"Canada","adm1name":"Manitoba","iso_a2":"CA"},"coordinates":[238427,852873]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Regina","adm0name":"Canada","adm1name":"Saskatchewan","iso_a2":"CA"},"coordinates":[209397,803606]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Saskatoon","adm0name":"Canada","adm1name":"Saskatchewan","iso_a2":"CA"},"coordinates":[203694,813796]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Calgary","adm0name":"Canada","adm1name":"Alberta","iso_a2":"CA"},"coordinates":[183106,807367]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Prince Rupert","adm0name":"Canada","adm1name":"British Columbia","iso_a2":"CA"},"coordinates":[137973,826514]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Victoria","adm0name":"Canada","adm1name":"British Columbia","iso_a2":"CA"},"coordinates":[157361,791657]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Arctic Bay","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[263425,937400]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Resolute","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[236389,947175]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Repulse Bay","adm0name":"Canada","adm1name":"Nunavut","iso_a2":"CA"},"coordinates":[260325,898868]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Yellowknife","adm0name":"Canada","adm1name":"Northwest Territories","iso_a2":"CA"},"coordinates":[182230,874652]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Fort Good Hope","adm0name":"Canada","adm1name":"Northwest Territories","iso_a2":"CA"},"coordinates":[142685,897311]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Whitehorse","adm0name":"Canada","adm1name":"Yukon","iso_a2":"CA"},"coordinates":[124861,864430]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Boa Vista","adm0name":"Brazil","adm1name":"Roraima","iso_a2":"BR"},"coordinates":[331483,521401]},{"type":"Point","properties":{"scalerank":3,"labelrank":1,"name":"Macapá","adm0name":"Brazil","adm1name":"Amapá","iso_a2":"BR"},"coordinates":[358194,504913]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Ottawa","adm0name":"Canada","adm1name":"Ontario","iso_a2":"CA"},"coordinates":[289716,773798]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Fort Severn","adm0name":"Canada","adm1name":"Ontario","iso_a2":"CA"},"coordinates":[256528,836387]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Thunder Bay","adm0name":"Canada","adm1name":"Ontario","iso_a2":"CA"},"coordinates":[252014,791734]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Québec","adm0name":"Canada","adm1name":"Québec","iso_a2":"CA"},"coordinates":[302095,782218]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Halifax","adm0name":"Canada","adm1name":"Nova Scotia","iso_a2":"CA"},"coordinates":[323333,769244]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"St. John’s","adm0name":"Canada","adm1name":"Newfoundland and Labrador","iso_a2":"CA"},"coordinates":[353663,786632]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Nain","adm0name":"Canada","adm1name":"Newfoundland and Labrador","iso_a2":"CA"},"coordinates":[328650,839729]},{"type":"Point","properties":{"scalerank":3,"labelrank":2,"name":"Charlottetown","adm0name":"Canada","adm1name":"Prince Edward Island","iso_a2":"CA"},"coordinates":[324635,778719]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Ndele","adm0name":"Central African Republic","adm1name":"Bamingui-Bangoran","iso_a2":"CF"},"coordinates":[557369,554537]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Belgrade","adm0name":"Serbia","adm1name":"Grad Beograd","iso_a2":"RS"},"coordinates":[556849,770254]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Obo","adm0name":"Central African Republic","adm1name":"Haut-Mbomou","iso_a2":"CF"},"coordinates":[573611,536709]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Bandar Seri Begawan","adm0name":"Brunei","adm1name":"Brunei and Muara","iso_a2":"BN"},"coordinates":[819259,533648]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Puerto Deseado","adm0name":"Argentina","adm1name":"Santa Cruz","iso_a2":"AR"},"coordinates":[316944,221825]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Rio Gallegos","adm0name":"Argentina","adm1name":"Santa Cruz","iso_a2":"AR"},"coordinates":[307731,198818]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Comodoro Rivadavia","adm0name":"Argentina","adm1name":"Chubut","iso_a2":"AR"},"coordinates":[312500,232962]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Mendoza","adm0name":"Argentina","adm1name":"Mendoza","iso_a2":"AR"},"coordinates":[308837,309913]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Sucre","adm0name":"Bolivia","adm1name":"Chuquisaca","iso_a2":"BO"},"coordinates":[318723,391910]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Riberalta","adm0name":"Bolivia","adm1name":"El Beni","iso_a2":"BO"},"coordinates":[316388,439649]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Bahia Blanca","adm0name":"Argentina","adm1name":"Ciudad de Buenos Aires","iso_a2":"AR"},"coordinates":[327041,275203]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Mar del Plata","adm0name":"Argentina","adm1name":"Ciudad de Buenos Aires","iso_a2":"AR"},"coordinates":[340055,279587]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Córdoba","adm0name":"Argentina","adm1name":"Córdoba","iso_a2":"AR"},"coordinates":[321710,318701]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Posadas","adm0name":"Argentina","adm1name":"Misiones","iso_a2":"AR"},"coordinates":[344763,342637]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Belmopan","adm0name":"Belize","adm1name":"Cayo","iso_a2":"BZ"},"coordinates":[253425,606926]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Bangui","adm0name":"Central African Republic","adm1name":"Bangui","iso_a2":"CF"},"coordinates":[551550,530587]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Maroua","adm0name":"Cameroon","adm1name":"Extrême-Nord","iso_a2":"CM"},"coordinates":[539790,567490]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Yaounde","adm0name":"Cameroon","adm1name":"Centre","iso_a2":"CM"},"coordinates":[531985,527636]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Tirana","adm0name":"Albania","adm1name":"Durrës","iso_a2":"AL"},"coordinates":[555051,749560]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Yerevan","adm0name":"Armenia","adm1name":"Erevan","iso_a2":"AM"},"coordinates":[623642,742780]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Baku","adm0name":"Azerbaijan","adm1name":"Baki","iso_a2":"AZ"},"coordinates":[638500,744048]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Kandahar","adm0name":"Afghanistan","adm1name":"Kandahar","iso_a2":"AF"},"coordinates":[682485,691989]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Phnom Penh","adm0name":"Cambodia","adm1name":"Phnom Penh","iso_a2":"KH"},"coordinates":[791428,573156]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Menongue","adm0name":"Angola","adm1name":"Cuando Cubango","iso_a2":"AO"},"coordinates":[549166,417825]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Huambo","adm0name":"Angola","adm1name":"Huambo","iso_a2":"AO"},"coordinates":[543772,429192]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"La Paz","adm0name":"Bolivia","adm1name":"La Paz","iso_a2":"BO"},"coordinates":[310688,406987]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Santa Cruz","adm0name":"Bolivia","adm1name":"Santa Cruz","iso_a2":"BO"},"coordinates":[324366,399546]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Oran","adm0name":"Algeria","adm1name":"Oran","iso_a2":"DZ"},"coordinates":[498272,716291]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Cotonou","adm0name":"Benin","adm1name":"Ouémé","iso_a2":"BJ"},"coordinates":[506994,542646]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Tamanrasset","adm0name":"Algeria","adm1name":"Tamanghasset","iso_a2":"DZ"},"coordinates":[515341,639705]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Ghardaia","adm0name":"Algeria","adm1name":"Ghardaïa","iso_a2":"DZ"},"coordinates":[510194,697202]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Sofia","adm0name":"Bulgaria","adm1name":"Grad Sofiya","iso_a2":"BG"},"coordinates":[564762,757604]},{"type":"Point","properties":{"scalerank":3,"labelrank":6,"name":"Minsk","adm0name":"Belarus","adm1name":"Minsk","iso_a2":"BY"},"coordinates":[576568,824056]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Thimphu","adm0name":"Bhutan","adm1name":"Thimphu","iso_a2":"BT"},"coordinates":[748997,667480]},{"type":"Point","properties":{"scalerank":3,"labelrank":7,"name":"Gaborone","adm0name":"Botswana","adm1name":"South-East","iso_a2":"BW"},"coordinates":[571977,358701]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Darwin","adm0name":"Australia","adm1name":"Northern Territory","iso_a2":"AU"},"coordinates":[863471,431104]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Alice Springs","adm0name":"Australia","adm1name":"Northern Territory","iso_a2":"AU"},"coordinates":[871888,364302]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Canberra","adm0name":"Australia","adm1name":"Australian Capital Territory","iso_a2":"AU"},"coordinates":[914247,295685]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Newcastle","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[921708,310126]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Adelaide","adm0name":"Australia","adm1name":"South Australia","iso_a2":"AU"},"coordinates":[884994,297758]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Townsville","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[907694,390672]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Brisbane","adm0name":"Australia","adm1name":"Queensland","iso_a2":"AU"},"coordinates":[925091,342073]},{"type":"Point","properties":{"scalerank":3,"labelrank":3,"name":"Hobart","adm0name":"Australia","adm1name":"Tasmania","iso_a2":"AU"},"coordinates":[909152,250854]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Ouagadougou","adm0name":"Burkina Faso","adm1name":"Kadiogo","iso_a2":"BF"},"coordinates":[495759,578016]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Sarajevo","adm0name":"Bosnia and Herzegovina","adm1name":"Sarajevo","iso_a2":"BA"},"coordinates":[551063,764505]},{"type":"Point","properties":{"scalerank":3,"labelrank":5,"name":"Naypyidaw","adm0name":"Myanmar","adm1name":"Mandalay","iso_a2":"MM"},"coordinates":[766990,621835]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"San Juan","adm0name":"Puerto Rico","iso_a2":"PR"},"coordinates":[316305,613964]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Stanley","adm0name":"Falkland Islands","iso_a2":"FK"},"coordinates":[339306,198423]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Hamilton","adm0name":"Bermuda","iso_a2":"BM"},"coordinates":[320044,696043]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Nukualofa","adm0name":"Tonga","iso_a2":"TO"},"coordinates":[13276,379483]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Hargeysa","adm0name":"Somaliland","iso_a2":"-99"},"coordinates":[622403,561355]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Victoria","adm0name":"Seychelles","iso_a2":"SC"},"coordinates":[654027,477366]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Sao Tome","adm0name":"Sao Tome and Principe","iso_a2":"ST"},"coordinates":[518703,506692]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Apia","adm0name":"Samoa","iso_a2":"WS"},"coordinates":[22948,422713]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Valletta","adm0name":"Malta","iso_a2":"MT"},"coordinates":[540318,717403]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Male","adm0name":"Maldives","iso_a2":"MV"},"coordinates":[704166,529403]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Jerusalem","adm0name":"Israel","adm1name":"Jerusalem","iso_a2":"IL"},"coordinates":[597795,692987]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Praia","adm0name":"Cape Verde","iso_a2":"CV"},"coordinates":[434676,593090]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Nassau","adm0name":"The Bahamas","iso_a2":"BS"},"coordinates":[285138,653322]},{"type":"Point","properties":{"scalerank":3,"labelrank":0,"name":"Nicosia","adm0name":"Cyprus","iso_a2":"CY"},"coordinates":[592684,713060]},{"type":"Point","properties":{"scalerank":3,"labelrank":8,"name":"Kaohsiung","adm0name":"Taiwan","adm1name":"Kaohsiung City","iso_a2":"TW"},"coordinates":[834073,638807]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Shenzhen","adm0name":"China","adm1name":"Guangdong","iso_a2":"CN"},"coordinates":[816999,638339]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Zibo","adm0name":"China","adm1name":"Shandong","iso_a2":"CN"},"coordinates":[827911,722749]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Minneapolis","adm0name":"United States of America","adm1name":"Minnesota","iso_a2":"US"},"coordinates":[240962,771210]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Honolulu","adm0name":"United States of America","adm1name":"Hawaii","iso_a2":"US"},"coordinates":[61500,630960]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Seattle","adm0name":"United States of America","adm1name":"Washington","iso_a2":"US"},"coordinates":[160161,786555]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Phoenix","adm0name":"United States of America","adm1name":"Arizona","iso_a2":"US"},"coordinates":[188689,703435]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"San Diego","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[174494,699169]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"St. Louis","adm0name":"United States of America","adm1name":"Missouri","iso_a2":"US"},"coordinates":[249328,733620]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"New Orleans","adm0name":"United States of America","adm1name":"Louisiana","iso_a2":"US"},"coordinates":[249883,682432]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Dallas","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[230995,699169]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Maracaibo","adm0name":"Venezuela","adm1name":"Zulia","iso_a2":"VE"},"coordinates":[300939,568298]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Boston","adm0name":"United States of America","adm1name":"Massachusetts","iso_a2":"US"},"coordinates":[302578,755511]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Tampa","adm0name":"United States of America","adm1name":"Florida","iso_a2":"US"},"coordinates":[270943,670299]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Philadelphia","adm0name":"United States of America","adm1name":"Pennsylvania","iso_a2":"US"},"coordinates":[291189,741707]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Detroit","adm0name":"United States of America","adm1name":"Michigan","iso_a2":"US"},"coordinates":[269217,755511]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Anchorage","adm0name":"United States of America","adm1name":"Alaska","iso_a2":"US"},"coordinates":[83611,867412]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Hanoi","adm0name":"Vietnam","adm1name":"Thái Nguyên","iso_a2":"VN"},"coordinates":[794022,629340]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Ho Chi Minh City","adm0name":"Vietnam","adm1name":"H? Chí Minh city","iso_a2":"VN"},"coordinates":[796369,568595]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Ankara","adm0name":"Turkey","adm1name":"Ankara","iso_a2":"TR"},"coordinates":[591284,741276]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Budapest","adm0name":"Hungary","adm1name":"Budapest","iso_a2":"HU"},"coordinates":[553003,786140]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Sanaa","adm0name":"Yemen","adm1name":"Amanat Al Asimah","iso_a2":"YE"},"coordinates":[622791,595697]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Barcelona","adm0name":"Spain","adm1name":"Cataluña","iso_a2":"ES"},"coordinates":[506059,749902]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Bucharest","adm0name":"Romania","adm1name":"Bucharest","iso_a2":"RO"},"coordinates":[572494,767972]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Aleppo","adm0name":"Syria","adm1name":"Aleppo (Halab)","iso_a2":"SY"},"coordinates":[603244,719372]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Damascus","adm0name":"Syria","adm1name":"Damascus","iso_a2":"SY"},"coordinates":[600827,703198]},{"type":"Point","properties":{"scalerank":2,"labelrank":7,"name":"Zürich","adm0name":"Switzerland","adm1name":"Zürich","iso_a2":"CH"},"coordinates":[523744,785429]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Lisbon","adm0name":"Portugal","adm1name":"Lisboa","iso_a2":"PT"},"coordinates":[474591,734139]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Khartoum","adm0name":"Sudan","adm1name":"Khartoum","iso_a2":"SD"},"coordinates":[590367,597079]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Jeddah","adm0name":"Saudi Arabia","adm1name":"Makkah","iso_a2":"SA"},"coordinates":[608936,632204]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Makkah","adm0name":"Saudi Arabia","adm1name":"Makkah","iso_a2":"SA"},"coordinates":[610605,631690]},{"type":"Point","properties":{"scalerank":2,"labelrank":7,"name":"Oslo","adm0name":"Norway","adm1name":"Oslo","iso_a2":"NO"},"coordinates":[529855,859702]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Lahore","adm0name":"Pakistan","adm1name":"Punjab","iso_a2":"PK"},"coordinates":[706522,691704]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Karachi","adm0name":"Pakistan","adm1name":"Sind","iso_a2":"PK"},"coordinates":[686077,652070]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Durban","adm0name":"South Africa","adm1name":"KwaZulu-Natal","iso_a2":"ZA"},"coordinates":[586050,327795]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"St. Petersburg","adm0name":"Russia","adm1name":"City of St. Petersburg","iso_a2":"RU"},"coordinates":[584205,859834]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Guadalajara","adm0name":"Mexico","adm1name":"Jalisco","iso_a2":"MX"},"coordinates":[212967,627187]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Puebla","adm0name":"Mexico","adm1name":"Puebla","iso_a2":"MX"},"coordinates":[227216,617589]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Kano","adm0name":"Nigeria","adm1name":"Kano","iso_a2":"NG"},"coordinates":[523661,575822]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Warsaw","adm0name":"Poland","adm1name":"Masovian","iso_a2":"PL"},"coordinates":[558328,814281]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Pyongyang","adm0name":"North Korea","adm1name":"P'yongyang","iso_a2":"KP"},"coordinates":[849312,735898]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Dar es Salaam","adm0name":"Tanzania","adm1name":"Dar-Es-Salaam","iso_a2":"TZ"},"coordinates":[609072,464442]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Medan","adm0name":"Indonesia","adm1name":"Sumatera Utara","iso_a2":"ID"},"coordinates":[774021,525938]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Dublin","adm0name":"Ireland","adm1name":"Dublin","iso_a2":"IE"},"coordinates":[482636,820698]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Monrovia","adm0name":"Liberia","adm1name":"Montserrado","iso_a2":"LR"},"coordinates":[470001,542128]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Naples","adm0name":"Italy","adm1name":"Campania","iso_a2":"IT"},"coordinates":[539564,746684]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Milan","adm0name":"Italy","adm1name":"Lombardia","iso_a2":"IT"},"coordinates":[525564,774114]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Kuala Lumpur","adm0name":"Malaysia","adm1name":"Selangor","iso_a2":"MY"},"coordinates":[782494,523489]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Lanzhou","adm0name":"China","adm1name":"Gansu","iso_a2":"CN"},"coordinates":[788304,718341]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Nanning","adm0name":"China","adm1name":"Guangxi","iso_a2":"CN"},"coordinates":[800883,639925]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Guiyang","adm0name":"China","adm1name":"Guizhou","iso_a2":"CN"},"coordinates":[796439,662201]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Chongqing","adm0name":"China","adm1name":"Chongqing","iso_a2":"CN"},"coordinates":[796091,679885]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Fuzhou","adm0name":"China","adm1name":"Fujian","iso_a2":"CN"},"coordinates":[831382,659239]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Guangzhou","adm0name":"China","adm1name":"Guangdong","iso_a2":"CN"},"coordinates":[814786,641850]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Dongguan","adm0name":"China","adm1name":"Guangdong","iso_a2":"CN"},"coordinates":[815951,641281]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Bandung","adm0name":"Indonesia","adm1name":"Jawa Barat","iso_a2":"ID"},"coordinates":[798799,463554]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Surabaya","adm0name":"Indonesia","adm1name":"Jawa Timur","iso_a2":"ID"},"coordinates":[813191,461781]},{"type":"Point","properties":{"scalerank":2,"labelrank":7,"name":"Guayaquil","adm0name":"Ecuador","adm1name":"Guayas","iso_a2":"EC"},"coordinates":[277994,491576]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Medellin","adm0name":"Colombia","adm1name":"Antioquia","iso_a2":"CO"},"coordinates":[290064,541905]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Cali","adm0name":"Colombia","adm1name":"Valle del Cauca","iso_a2":"CO"},"coordinates":[287494,524872]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Havana","adm0name":"Cuba","adm1name":"Ciudad de la Habana","iso_a2":"CU"},"coordinates":[271205,641773]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Alexandria","adm0name":"Egypt","adm1name":"Al Iskandariyah","iso_a2":"EG"},"coordinates":[583188,689572]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Frankfurt","adm0name":"Germany","adm1name":"Hessen","iso_a2":"DE"},"coordinates":[524097,801532]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Hamburg","adm0name":"Germany","adm1name":"Hamburg","iso_a2":"DE"},"coordinates":[527772,821983]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Munich","adm0name":"Germany","adm1name":"Bayern","iso_a2":"DE"},"coordinates":[532147,789872]},{"type":"Point","properties":{"scalerank":2,"labelrank":7,"name":"Prague","adm0name":"Czech Republic","adm1name":"Prague","iso_a2":"CZ"},"coordinates":[540177,801445]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Kuwait","adm0name":"Kuwait","adm1name":"Al Kuwayt","iso_a2":"KW"},"coordinates":[633267,678728]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Xian","adm0name":"China","adm1name":"Shaanxi","iso_a2":"CN"},"coordinates":[802479,707789]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Taiyuan","adm0name":"China","adm1name":"Shanxi","iso_a2":"CN"},"coordinates":[812619,729117]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Wuhan","adm0name":"China","adm1name":"Hubei","iso_a2":"CN"},"coordinates":[817411,685898]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Changsha","adm0name":"China","adm1name":"Hunan","iso_a2":"CN"},"coordinates":[813800,671798]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Kunming","adm0name":"China","adm1name":"Yunnan","iso_a2":"CN"},"coordinates":[785216,653255]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Zhengzhou","adm0name":"China","adm1name":"Henan","iso_a2":"CN"},"coordinates":[815730,710633]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Shenyeng","adm0name":"China","adm1name":"Liaoning","iso_a2":"CN"},"coordinates":[842910,752400]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Jinan","adm0name":"China","adm1name":"Shandong","iso_a2":"CN"},"coordinates":[824980,722008]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Tianjin","adm0name":"China","adm1name":"Tianjin","iso_a2":"CN"},"coordinates":[825549,736553]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Nanchang","adm0name":"China","adm1name":"Jiangxi","iso_a2":"CN"},"coordinates":[821883,674642]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Nanjing","adm0name":"China","adm1name":"Jiangsu","iso_a2":"CN"},"coordinates":[829938,694608]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Hangzhou","adm0name":"China","adm1name":"Zhejiang","iso_a2":"CN"},"coordinates":[833799,683943]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Hiroshima","adm0name":"Japan","adm1name":"Hiroshima","iso_a2":"JP"},"coordinates":[867890,708458]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Changchun","adm0name":"China","adm1name":"Jilin","iso_a2":"CN"},"coordinates":[848161,764605]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Baotou","adm0name":"China","adm1name":"Nei Mongol","iso_a2":"CN"},"coordinates":[805055,745571]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Harbin","adm0name":"China","adm1name":"Heilongjiang","iso_a2":"CN"},"coordinates":[851800,775772]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Sapporo","adm0name":"Japan","adm1name":"Hokkaido","iso_a2":"JP"},"coordinates":[892605,759924]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Santo Domingo","adm0name":"Dominican Republic","adm1name":"Distrito Nacional","iso_a2":"DO"},"coordinates":[305828,614153]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Accra","adm0name":"Ghana","adm1name":"Greater Accra","iso_a2":"GH"},"coordinates":[499392,537610]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Delhi","adm0name":"India","adm1name":"Delhi","iso_a2":"IN"},"coordinates":[714522,674583]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Hyderabad","adm0name":"India","adm1name":"Andhra Pradesh","iso_a2":"IN"},"coordinates":[717993,607814]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Pune","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[705133,614509]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Nagpur","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[719688,630149]},{"type":"Point","properties":{"scalerank":2,"labelrank":7,"name":"Tripoli","adm0name":"Libya","adm1name":"Tajura' wa an Nawahi al Arba","iso_a2":"LY"},"coordinates":[536611,699587]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Tel Aviv-Yafo","adm0name":"Israel","adm1name":"Tel Aviv","iso_a2":"IL"},"coordinates":[596577,694785]},{"type":"Point","properties":{"scalerank":2,"labelrank":7,"name":"Helsinki","adm0name":"Finland","adm1name":"Southern Finland","iso_a2":"FI"},"coordinates":[569256,861236]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Mashhad","adm0name":"Iran","adm1name":"Razavi Khorasan","iso_a2":"IR"},"coordinates":[665466,719608]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Jaipur","adm0name":"India","adm1name":"Rajasthan","iso_a2":"IN"},"coordinates":[710577,664222]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Kanpur","adm0name":"India","adm1name":"Uttar Pradesh","iso_a2":"IN"},"coordinates":[723105,661490]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Patna","adm0name":"India","adm1name":"Bihar","iso_a2":"IN"},"coordinates":[736466,656543]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Chennai","adm0name":"India","adm1name":"Tamil Nadu","iso_a2":"IN"},"coordinates":[722994,582279]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Ahmedabad","adm0name":"India","adm1name":"Dadra and Nagar Haveli","iso_a2":"IN"},"coordinates":[701605,641169]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Surat","adm0name":"India","adm1name":"Dadra and Nagar Haveli","iso_a2":"IN"},"coordinates":[702327,630327]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"København","adm0name":"Denmark","adm1name":"Hovedstaden","iso_a2":"DK"},"coordinates":[534893,834594]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Abidjan","adm0name":"Ivory Coast","adm1name":"Lagunes","iso_a2":"CI"},"coordinates":[488772,536247]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Belem","adm0name":"Brazil","adm1name":"Pará","iso_a2":"BR"},"coordinates":[365327,496138]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Brasilia","adm0name":"Brazil","adm1name":"Distrito Federal","iso_a2":"BR"},"coordinates":[366894,411221]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Porto Alegre","adm0name":"Brazil","adm1name":"Rio Grande do Sul","iso_a2":"BR"},"coordinates":[357772,326699]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Curitiba","adm0name":"Brazil","adm1name":"Paraná","iso_a2":"BR"},"coordinates":[362994,354129]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Fortaleza","adm0name":"Brazil","adm1name":"Ceará","iso_a2":"BR"},"coordinates":[392828,482512]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Salvador","adm0name":"Brazil","adm1name":"Bahia","iso_a2":"BR"},"coordinates":[393106,427888]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Edmonton","adm0name":"Canada","adm1name":"Alberta","iso_a2":"CA"},"coordinates":[184717,821983]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Montréal","adm0name":"Canada","adm1name":"Québec","iso_a2":"CA"},"coordinates":[295596,774292]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Goiania","adm0name":"Brazil","adm1name":"Goiás","iso_a2":"BR"},"coordinates":[363050,405672]},{"type":"Point","properties":{"scalerank":2,"labelrank":1,"name":"Recife","adm0name":"Brazil","adm1name":"Pernambuco","iso_a2":"BR"},"coordinates":[403006,456885]},{"type":"Point","properties":{"scalerank":2,"labelrank":8,"name":"Brussels","adm0name":"Belgium","adm1name":"Brussels","iso_a2":"BE"},"coordinates":[512031,805888]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Dhaka","adm0name":"Bangladesh","adm1name":"Dhaka","iso_a2":"BD"},"coordinates":[751129,645275]},{"type":"Point","properties":{"scalerank":2,"labelrank":6,"name":"Luanda","adm0name":"Angola","adm1name":"Luanda","iso_a2":"AO"},"coordinates":[536756,452367]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Algiers","adm0name":"Algeria","adm1name":"Alger","iso_a2":"DZ"},"coordinates":[508468,722530]},{"type":"Point","properties":{"scalerank":2,"labelrank":2,"name":"Chittagong","adm0name":"Bangladesh","adm1name":"Chittagong","iso_a2":"BD"},"coordinates":[754993,637022]},{"type":"Point","properties":{"scalerank":2,"labelrank":3,"name":"Perth","adm0name":"Australia","adm1name":"Western Australia","iso_a2":"AU"},"coordinates":[821772,315413]},{"type":"Point","properties":{"scalerank":2,"labelrank":5,"name":"Rangoon","adm0name":"Myanmar","adm1name":"Yangon","iso_a2":"MM"},"coordinates":[767123,604161]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"San Francisco","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[159952,728479]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Denver","adm0name":"United States of America","adm1name":"Colorado","iso_a2":"US"},"coordinates":[208372,740162]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Houston","adm0name":"United States of America","adm1name":"Texas","iso_a2":"US"},"coordinates":[235161,681396]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Miami","adm0name":"United States of America","adm1name":"Florida","iso_a2":"US"},"coordinates":[277150,657506]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Atlanta","adm0name":"United States of America","adm1name":"Georgia","iso_a2":"US"},"coordinates":[265550,705153]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Chicago","adm0name":"United States of America","adm1name":"Illinois","iso_a2":"US"},"coordinates":[256244,752549]},{"type":"Point","properties":{"scalerank":1,"labelrank":6,"name":"Caracas","adm0name":"Venezuela","adm1name":"Distrito Capital","iso_a2":"VE"},"coordinates":[314114,566941]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Kiev","adm0name":"Ukraine","adm1name":"Kiev","iso_a2":"UA"},"coordinates":[584762,803519]},{"type":"Point","properties":{"scalerank":1,"labelrank":8,"name":"Dubai","adm0name":"United Arab Emirates","adm1name":"Dubay","iso_a2":"AE"},"coordinates":[653549,654203]},{"type":"Point","properties":{"scalerank":1,"labelrank":6,"name":"Tashkent","adm0name":"Uzbekistan","adm1name":"Tashkent","iso_a2":"UZ"},"coordinates":[692480,749478]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Madrid","adm0name":"Spain","adm1name":"Comunidad de Madrid","iso_a2":"ES"},"coordinates":[489762,744077]},{"type":"Point","properties":{"scalerank":1,"labelrank":7,"name":"Geneva","adm0name":"Switzerland","adm1name":"Genève","iso_a2":"CH"},"coordinates":[517055,778486]},{"type":"Point","properties":{"scalerank":1,"labelrank":7,"name":"Stockholm","adm0name":"Sweden","adm1name":"Stockholm","iso_a2":"SE"},"coordinates":[550264,856349]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Bangkok","adm0name":"Thailand","adm1name":"Bangkok Metropolis","iso_a2":"TH"},"coordinates":[779207,586190]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Lima","adm0name":"Peru","adm1name":"Lima","iso_a2":"PE"},"coordinates":[285967,433351]},{"type":"Point","properties":{"scalerank":1,"labelrank":8,"name":"Dakar","adm0name":"Senegal","adm1name":"Dakar","iso_a2":"SM"},"coordinates":[451458,591912]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Johannesburg","adm0name":"South Africa","adm1name":"Gauteng","iso_a2":"ZA"},"coordinates":[577855,349685]},{"type":"Point","properties":{"scalerank":1,"labelrank":8,"name":"Amsterdam","adm0name":"Netherlands","adm1name":"Noord-Holland","iso_a2":"NL"},"coordinates":[513651,814873]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Casablanca","adm0name":"Morocco","adm1name":"Grand Casablanca","iso_a2":"MA"},"coordinates":[478837,703790]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Seoul","adm0name":"South Korea","adm1name":"Seoul","iso_a2":"KR"},"coordinates":[852771,727288]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Manila","adm0name":"Philippines","adm1name":"Metropolitan Manila","iso_a2":"PH"},"coordinates":[836056,591250]},{"type":"Point","properties":{"scalerank":1,"labelrank":2,"name":"Monterrey","adm0name":"Mexico","adm1name":"Nuevo León","iso_a2":"MX"},"coordinates":[221300,656809]},{"type":"Point","properties":{"scalerank":1,"labelrank":8,"name":"Auckland","adm0name":"New Zealand","adm1name":"Auckland","iso_a2":"NZ"},"coordinates":[985452,286413]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Berlin","adm0name":"Germany","adm1name":"Berlin","iso_a2":"DE"},"coordinates":[537221,815891]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Urumqi","adm0name":"China","adm1name":"Xinjiang Uygur","iso_a2":"CN"},"coordinates":[743258,764249]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Chengdu","adm0name":"China","adm1name":"Sichuan","iso_a2":"CN"},"coordinates":[789078,686432]},{"type":"Point","properties":{"scalerank":1,"labelrank":2,"name":"Osaka","adm0name":"Japan","adm1name":"Osaka","iso_a2":"JP"},"coordinates":[876272,710604]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Kinshasa","adm0name":"Congo (Kinshasa)","adm1name":"Kinshasa City","iso_a2":"CD"},"coordinates":[542536,479077]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"New Delhi","adm0name":"India","adm1name":"Delhi","iso_a2":"IN"},"coordinates":[714444,674156]},{"type":"Point","properties":{"scalerank":1,"labelrank":1,"name":"Bangalore","adm0name":"India","adm1name":"Karnataka","iso_a2":"IN"},"coordinates":[715438,581569]},{"type":"Point","properties":{"scalerank":1,"labelrank":6,"name":"Athens","adm0name":"Greece","adm1name":"Attiki","iso_a2":"GR"},"coordinates":[565920,729759]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Baghdad","adm0name":"Iraq","adm1name":"Baghdad","iso_a2":"IQ"},"coordinates":[623310,702242]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Addis Ababa","adm0name":"Ethiopia","adm1name":"Addis Ababa","iso_a2":"ET"},"coordinates":[607494,558246]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Tehran","adm0name":"Iran","adm1name":"Tehran","iso_a2":"IR"},"coordinates":[642839,716065]},{"type":"Point","properties":{"scalerank":1,"labelrank":2,"name":"Vancouver","adm0name":"Canada","adm1name":"British Columbia","iso_a2":"CA"},"coordinates":[157990,796647]},{"type":"Point","properties":{"scalerank":1,"labelrank":2,"name":"Toronto","adm0name":"Canada","adm1name":"Ontario","iso_a2":"CA"},"coordinates":[279383,763627]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Buenos Aires","adm0name":"Argentina","adm1name":"Ciudad de Buenos Aires","iso_a2":"AR"},"coordinates":[337779,299727]},{"type":"Point","properties":{"scalerank":1,"labelrank":5,"name":"Kabul","adm0name":"Afghanistan","adm1name":"Kabul","iso_a2":"AF"},"coordinates":[692169,709221]},{"type":"Point","properties":{"scalerank":1,"labelrank":7,"name":"Vienna","adm0name":"Austria","adm1name":"Wien","iso_a2":"AT"},"coordinates":[545457,790287]},{"type":"Point","properties":{"scalerank":1,"labelrank":3,"name":"Melbourne","adm0name":"Australia","adm1name":"Victoria","iso_a2":"AU"},"coordinates":[902702,280666]},{"type":"Point","properties":{"scalerank":1,"labelrank":8,"name":"Taipei","adm0name":"Taiwan","adm1name":"Taipei City","iso_a2":"TW"},"coordinates":[837688,653040]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Los Angeles","adm0name":"United States of America","adm1name":"California","iso_a2":"US"},"coordinates":[171717,706100]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Washington, D.C.","adm0name":"United States of America","adm1name":"District of Columbia","iso_a2":"US"},"coordinates":[286079,735187]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"New York","adm0name":"United States of America","adm1name":"New York","iso_a2":"US"},"coordinates":[294495,746150]},{"type":"Point","properties":{"scalerank":0,"labelrank":5,"name":"London","adm0name":"United Kingdom","adm1name":"Westminster","iso_a2":"GB"},"coordinates":[499670,809838]},{"type":"Point","properties":{"scalerank":0,"labelrank":5,"name":"Istanbul","adm0name":"Turkey","adm1name":"Istanbul","iso_a2":"TR"},"coordinates":[580577,748253]},{"type":"Point","properties":{"scalerank":0,"labelrank":5,"name":"Riyadh","adm0name":"Saudi Arabia","adm1name":"Ar Riyad","iso_a2":"SA"},"coordinates":[629918,650712]},{"type":"Point","properties":{"scalerank":0,"labelrank":3,"name":"Cape Town","adm0name":"South Africa","adm1name":"Western Cape","iso_a2":"ZA"},"coordinates":[551202,303771]},{"type":"Point","properties":{"scalerank":0,"labelrank":2,"name":"Moscow","adm0name":"Russia","adm1name":"Moskva","iso_a2":"RU"},"coordinates":[604482,835030]},{"type":"Point","properties":{"scalerank":0,"labelrank":2,"name":"Mexico City","adm0name":"Mexico","adm1name":"Distrito Federal","iso_a2":"MX"},"coordinates":[224631,619915]},{"type":"Point","properties":{"scalerank":0,"labelrank":2,"name":"Lagos","adm0name":"Nigeria","adm1name":"Lagos","iso_a2":"NG"},"coordinates":[509415,542902]},{"type":"Point","properties":{"scalerank":0,"labelrank":3,"name":"Rome","adm0name":"Italy","adm1name":"Lazio","iso_a2":"IT"},"coordinates":[534670,752939]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Beijing","adm0name":"China","adm1name":"Beijing","iso_a2":"CN"},"coordinates":[823294,741286]},{"type":"Point","properties":{"scalerank":0,"labelrank":5,"name":"Nairobi","adm0name":"Kenya","adm1name":"Nairobi","iso_a2":"KE"},"coordinates":[602262,497125]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Jakarta","adm0name":"Indonesia","adm1name":"Jakarta Raya","iso_a2":"ID"},"coordinates":[796742,468148]},{"type":"Point","properties":{"scalerank":0,"labelrank":5,"name":"Bogota","adm0name":"Colombia","adm1name":"Bogota","iso_a2":"CO"},"coordinates":[294207,531960]},{"type":"Point","properties":{"scalerank":0,"labelrank":3,"name":"Cairo","adm0name":"Egypt","adm1name":"Al Qahirah","iso_a2":"EG"},"coordinates":[586799,682758]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Shanghai","adm0name":"China","adm1name":"Shanghai","iso_a2":"CN"},"coordinates":[837317,689669]},{"type":"Point","properties":{"scalerank":0,"labelrank":2,"name":"Tokyo","adm0name":"Japan","adm1name":"Tokyo","iso_a2":"JP"},"coordinates":[888192,716142]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Mumbai","adm0name":"India","adm1name":"Maharashtra","iso_a2":"IN"},"coordinates":[702374,617394]},{"type":"Point","properties":{"scalerank":0,"labelrank":3,"name":"Paris","adm0name":"France","adm1name":"Île-de-France","iso_a2":"FR"},"coordinates":[506476,794237]},{"type":"Point","properties":{"scalerank":0,"labelrank":3,"name":"Santiago","adm0name":"Chile","adm1name":"Región Metropolitana de Santiago","iso_a2":"CL"},"coordinates":[303697,306556]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Kolkata","adm0name":"India","adm1name":"West Bengal","iso_a2":"IN"},"coordinates":[745340,637999]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Rio de Janeiro","adm0name":"Brazil","adm1name":"Rio de Janeiro","iso_a2":"BR"},"coordinates":[379925,368910]},{"type":"Point","properties":{"scalerank":0,"labelrank":1,"name":"Sao Paulo","adm0name":"Brazil","adm1name":"São Paulo","iso_a2":"BR"},"coordinates":[370480,365156]},{"type":"Point","properties":{"scalerank":0,"labelrank":3,"name":"Sydney","adm0name":"Australia","adm1name":"New South Wales","iso_a2":"AU"},"coordinates":[919952,303771]},{"type":"Point","properties":{"scalerank":0,"labelrank":0,"name":"Singapore","adm0name":"Singapore","iso_a2":"SG"},"coordinates":[788482,512389]},{"type":"Point","properties":{"scalerank":0,"labelrank":0,"name":"Hong Kong","adm0name":"Hong Kong S.A.R.","iso_a2":"HK"},"coordinates":[817174,636873]}]}},"arcs":[[[85470,47819],[-4327,823],[2549,633],[1778,-1456]],[[135926,62848],[-3045,936],[3281,69],[-236,-1005]],[[54258,21344],[-9164,857],[3638,778],[5526,-1635]],[[965673,41772],[-3773,-978],[-481,1285],[4254,-307]],[[964121,46628],[6302,-1202],[-5445,-735],[-1844,-1198],[-1422,1933],[2409,1202]],[[405738,34787],[-7075,156],[2628,1209],[4447,-1365]],[[313161,34004],[-3575,90],[1434,1245],[2141,-1335]],[[334072,28722],[-2358,-3579],[-10237,1183],[-4755,2157],[12074,239],[-141,1922],[5030,1437],[387,-3359]],[[316184,30299],[-4542,3151],[4985,-464],[1416,-1955],[-1859,-732]],[[58185,31902],[-3469,-220],[-10900,3103],[279,1928],[2416,1619],[4480,-1060],[5441,-2972],[1753,-2398]],[[374381,37806],[4168,-45],[2102,-3902],[-1562,-4233],[-15721,-2674],[-1627,-838],[-12193,-508],[-513,1781],[2648,2728],[3047,-191],[5438,3920],[-1094,1165],[1427,4014],[3162,3306],[6265,1552],[3599,-570],[4781,-2400],[-384,-1842],[-3543,-1263]],[[304627,32657],[-4026,1396],[3443,3321],[8515,3087],[2085,-125],[-7008,-4897],[-3009,-2782]],[[146206,62619],[-2132,1749],[2510,-580],[-378,-1169]],[[149084,70536],[2927,-2396],[3427,-1125],[571,-2044],[-2879,102],[-6552,2931],[-99,2426],[2605,106]],[[442757,66979],[329,-3590],[-2221,2981],[1967,2192],[-75,-1583]],[[165121,67753],[789,-1381],[-2196,-2064],[-4990,-31],[652,2234],[-1354,1679],[7099,-437]],[[175726,65330],[-1773,486],[2764,1288],[-991,-1774]],[[167919,65653],[-704,1752],[2341,30],[-1637,-1782]],[[227524,78675],[1407,176],[346,-1810],[1641,1998],[2068,-264],[-1873,-2157],[3305,1133],[94,-2024],[-1323,-990],[-6544,175],[-4965,1629],[-5749,813],[380,889],[5676,880],[698,-1240],[3382,1673],[1457,-881]],[[293460,71648],[-612,-3038],[-3684,1650],[-324,1466],[1780,1570],[2508,-435],[332,-1213]],[[246776,71151],[-1415,3309],[2396,79],[-981,-3388]],[[492964,85456],[1224,-315],[-1920,-2052],[-2094,2867],[2790,-500]],[[295259,86241],[-1384,-1711],[-5476,-1234],[-900,1132],[5678,2100],[2082,-287]],[[331597,134082],[2154,-324],[-1032,-773],[-1122,1097]],[[346762,142020],[-615,875],[1989,-263],[-1374,-612]],[[338949,137923],[941,-643],[-3790,-1122],[890,1194],[1959,571]],[[396935,184408],[2158,-1100],[1466,-3035],[-797,-612],[-1169,2107],[-2754,2105],[1096,535]],[[301693,184601],[1024,-467],[-337,-1554],[-917,846],[-1320,-412],[-728,1500],[594,965],[1684,-878]],[[302801,179654],[1594,-243],[38,-1520],[-1488,614],[-144,1149]],[[306380,179352],[2229,-586],[1717,-1406],[651,-2298],[-3469,2828],[-319,-1650],[-1578,1759],[769,1353]],[[313667,177961],[-2751,-400],[-642,1425],[2933,19],[460,-1044]],[[320696,180552],[2039,-51],[-2284,-1052],[245,1103]],[[341609,129267],[-3451,-2313],[-2179,-2791],[726,-1717],[-1874,1058],[-1395,-508],[-3047,-3257],[-1787,-37],[349,-1175],[-1577,-865],[1081,-1407],[-1525,-1607],[1294,-1653],[2573,1177],[965,-347],[-1286,-2116],[-1121,1137],[-1232,-897],[-2240,348],[-63,-2623],[-1536,2467],[-1375,-83],[-903,-2230],[897,-1298],[-2587,397],[-947,-2416],[-1156,-666],[-545,-4764],[2065,-225],[-1544,-998],[584,-1459],[3231,-1115],[783,1786],[946,-3389],[2793,-3213],[1360,-3174],[-1158,-1450],[2425,-745],[-1267,-2429],[1800,194],[457,-2810],[-2251,-1870],[2070,555],[496,-1517],[-3393,-1171],[4270,-326],[-31,-2093],[1690,-5031],[-2665,-315],[-2575,1070],[1032,-2087],[2106,-663],[-67,-1532],[-2598,-957],[2929,-1237],[-631,-1407],[-3401,220],[853,-2498],[-2168,746],[-3378,-1584],[1927,-885],[-2913,-825],[2545,-940],[-8353,-3329],[-8150,-1998],[-2197,-1800],[-4731,-581],[-9638,1015],[-5338,-287],[2615,-3822],[2394,-1180],[7041,-689],[-1110,-1801],[-4335,-1679],[-8139,1406],[-7943,1116],[-2331,-791],[11134,-3252],[-1212,-1842],[-6731,-459],[-6434,2433],[-3207,-290],[1397,-1855],[4442,-1929],[2136,-2381],[1120,1072],[11082,-29],[1299,-1786],[-1462,-1638],[-8618,-553],[4123,-1006],[4913,426],[3252,-4193],[6131,-680],[5588,1767],[2163,-1407],[15247,-3939],[-3239,-2255],[2769,-2775],[11279,1090],[-5213,-2019],[4265,-3381],[-1082,-1441],[5760,-694],[6007,3662],[9354,3791],[15626,1826],[5841,-325],[229,-3344],[6962,1430],[6210,5761],[7394,2462],[4340,-1077],[5229,2449],[16661,2835],[14135,653],[-165,1725],[-15693,1016],[-1018,2583],[-7440,-388],[-9014,2692],[162,1813],[3037,3740],[2810,2439],[5591,1573],[8693,4678],[7985,2447],[4971,1126],[7877,497],[2629,1133],[6063,360],[-1235,1121],[4029,5380],[4517,-435],[3053,2783],[-3264,-47],[-2139,1786],[2563,3243],[3542,-156],[65,2310],[3025,-308],[4754,2204],[1497,3028],[-3305,1707],[-565,1328],[1912,346],[1926,-1356],[2601,2544],[-182,1210],[2412,-1467],[1623,-2955],[2591,748],[-445,3592],[5382,1348],[968,-853],[-1472,-2780],[9182,30],[2215,-667],[2630,994],[1458,-2647],[3828,3022],[4929,1792],[6954,1448],[6356,954],[766,818],[2351,-695],[1718,1719],[5727,-3230],[1383,-224],[3790,4224],[2103,-1715],[5512,114],[2161,712],[345,-1147],[3932,-847],[855,1483],[2122,-19],[191,-3609],[5015,349],[1762,3465],[1836,-1282],[-367,-993],[2070,-993],[2291,2403],[1200,-261],[1447,-2627],[4839,-755],[6702,2586],[3033,1676],[3821,440],[3451,1334],[1023,2230],[-1172,3259],[1539,2280],[2976,-77],[-1054,-2352],[2173,28],[2114,-3476],[3783,173],[1100,-939],[2860,-80],[1986,-1077],[2349,3438],[441,2716],[3525,2323],[3545,1323],[1141,1354],[5222,1297],[805,800],[3916,899],[446,2070],[2342,-835],[-794,-970],[4253,-1312],[-49,1621],[1636,1741],[-2095,1085],[2172,605],[3627,-1499],[-612,4443],[4311,2516],[4965,954],[3544,-340],[2118,-970],[4098,-3159],[-2888,-76],[682,-2060],[-657,-1724],[2047,1235],[2044,250],[3083,-1276],[1426,-1514],[3420,591],[6127,-1555],[2809,826],[12858,-2259],[3024,869],[1571,-4273],[-1244,-1616],[265,-2930],[-2009,-838],[489,-2924],[-2512,172],[-2528,-2582],[1869,-887],[3000,580],[637,-627],[-1048,-3579],[-2321,-2110],[-1681,-3625],[-2397,-7146],[2091,-539],[1804,1271],[1245,3381],[3153,833],[4783,4447],[1745,5434],[2376,1842],[172,1775],[3111,2091],[4116,-889],[1298,1881],[5351,3002],[2524,4686],[1560,940],[7581,2544],[3576,515],[3280,2894],[3405,-277],[6304,2209],[4773,-206],[3675,1306],[2922,562],[5252,-1077],[2432,1115],[1948,-767],[4332,778],[1719,-638],[1495,828],[1670,-1205],[5640,1853],[1626,2411],[3332,509],[3547,-728],[6766,-2503],[2178,-355],[1706,-1146],[4661,-1449],[3220,2279],[790,2650],[6090,1640],[1697,-769],[1742,-2551],[2704,-1189],[902,-1247],[-1004,-1521],[-3563,-1089],[99,-1358],[7463,2333],[6254,-577],[3917,954],[-3040,-1277],[-410,-1015],[6541,1657],[1931,1371],[5592,1533],[2498,-239],[2139,1638],[2221,-789],[2433,-3279],[2472,-402],[2240,459],[1400,3394],[3363,1643],[2442,-264],[4482,916],[2040,-1159],[307,-1183],[2951,2071],[3338,-1847],[933,588],[3474,-1210],[3060,-179],[1829,-837],[5758,-542],[1985,-1221],[2897,807],[502,-1269],[1973,-300],[-1887,-3867],[686,-625],[2608,1622],[2354,11],[2368,-2017],[738,-2395],[3786,-581],[7255,486],[195,-2248],[3909,205],[1488,-753],[1732,758],[222,2246],[1153,-404],[3668,-3594],[5447,-1684],[1254,758],[5142,-2021],[1183,-2684],[2298,-2028],[1076,-3019],[2395,-842],[-686,2039],[1765,1897],[1872,-1873],[2929,653],[6177,-911],[2618,-864],[1674,-2210],[5528,-2650],[759,1254],[1177,-2665],[-1764,-470],[-490,-3867],[-3692,1281],[3083,-2039],[-788,-1906],[-6637,-574],[1278,-1122],[-1721,-1230],[-3155,-287],[-1426,-1698],[-604,2942],[-1055,-1043],[72,-2782],[1465,-3349],[-4355,178],[-1305,813],[-1837,-2351],[-196,-2037],[-4508,-993],[2758,-413],[2533,-2617],[-146,-5329],[2379,-4958],[2227,-1783],[-1232,-2017],[1807,-543],[2192,1620],[683,-1562],[3875,-1260],[-6732,-503],[-5542,-1744],[-2450,2089],[539,-2873],[-2113,303],[-3595,-5214],[1840,-898],[-5515,-2446],[5469,-9],[-201,-5425],[6531,-3114],[2386,-1902],[-6658,-1793],[9622,802],[10723,-4212],[-1382,-1756],[7520,-437],[3056,-1235],[21397,-3698],[-975899,-1624],[13312,-1639],[11050,-486],[17983,-1689],[-1464,2223],[-18283,1673],[-3840,4281],[-7229,-28],[-4485,2139],[-13607,3256],[6236,312],[9455,-2260],[6113,167],[4921,-1581],[13891,-447],[5600,1164],[-697,1285],[6167,882],[6804,3147],[-4743,3015],[2114,1425],[-8545,2257],[1401,929],[23349,1550],[-6814,3241],[986,1206],[5287,469],[391,1749],[-6429,1364],[-4446,1801],[-8662,1639],[-3498,1952],[6044,2229],[-8259,352],[-3428,2496],[798,3681],[5561,304],[6393,-717],[1248,-1120],[4837,-59],[5559,-2202],[4339,1985],[-1156,2117],[8042,-2268],[-205,4378],[-4416,1840],[-3502,-326],[-2925,758],[3706,1547],[6763,-1896],[-1382,1923],[7801,3176],[3457,432],[4044,-1512],[-969,1186],[4255,1972],[5758,814],[2970,-378],[892,1797],[2406,863],[5241,-957],[3961,511],[6273,-746],[5355,1020],[6971,29],[4067,-346],[11702,700],[2836,1553],[9862,-371],[874,2764],[3562,-593],[-1672,-5291],[6706,1123],[-305,3096],[1738,484],[2426,-1059],[19,-2037],[-3230,-2505],[6086,-305],[7369,-943],[4771,1357],[8870,-72],[1859,-1745],[6893,1374],[-5321,1880],[986,2118],[-3148,174],[-1288,2736],[-2921,829],[990,1585],[3959,-834],[5803,865],[-3124,1236],[-7374,484],[-1943,2973],[2810,349],[-127,-1346],[12213,-273],[5167,-1634],[2544,527],[2751,-550],[5570,797],[3667,-834],[1737,2023],[2651,523],[594,1164],[2591,-981],[-625,-2195],[2829,220],[1008,-959],[3435,959],[2279,-1836],[7771,-2103],[2430,703],[129,2508],[2572,-588],[-294,2783],[2557,-861],[3094,-2761],[4326,565],[-551,-2273],[4877,1217],[3603,-362],[2964,1491],[11411,2029],[3206,1606],[2331,4409],[-1757,3338],[-123,2779],[-1066,3768],[-1431,2382],[-846,3480],[3712,118],[1102,1489],[-1154,1777],[1659,3678],[-991,1278],[1236,2948],[-2172,-119],[-18,2574],[1495,758],[1342,-1529],[-152,2974],[1887,538],[639,3049],[5054,3881],[-667,1785],[772,862],[1948,-628],[-169,1166],[4133,2002],[2133,3147],[3761,1497],[1772,1593],[4118,1929],[1025,-868]],[[291702,91617],[1526,-1313],[-1142,-1361],[-2438,493],[126,1270],[1928,911]],[[574603,87746],[-2366,49],[821,1781],[1545,-1830]],[[305413,94809],[1762,-2334],[3003,-7541],[263,-5724],[-2689,-4219],[-3706,-771],[-5067,-32],[-1997,1551],[1300,788],[4849,-542],[-2847,1455],[2991,1270],[-3995,1571],[-1857,-1661],[-1959,514],[-1008,-1985],[-3770,1728],[168,1561],[2502,-139],[568,1513],[5488,284],[-2358,1232],[3728,-107],[1456,949],[3788,421],[-3386,898],[-32,1334],[2036,1042],[1971,-218],[-1970,1411],[-2202,-217],[-2037,1346],[308,3237],[-875,2499],[4558,1257],[1016,-2371]],[[545062,89959],[-1454,1389],[2573,136],[-1119,-1525]],[[300040,91791],[-2198,317],[141,1374],[2057,-1691]],[[327784,91653],[-1583,3208],[2088,-1160],[-505,-2048]],[[311143,104968],[-2536,-1599],[-503,2021],[2074,3560],[1404,1054],[-383,-2416],[779,-682],[-835,-1938]],[[780503,115614],[-1975,438],[1703,1334],[272,-1772]],[[324498,122769],[956,-605],[-2508,-1556],[-1480,811],[2185,2590],[847,-1240]],[[326873,123038],[-1170,-280],[1161,2714],[751,-739],[-742,-1695]],[[339316,125233],[1657,-257],[-125,-1598],[-1602,-201],[-1462,1576],[1311,1947],[221,-1467]],[[345755,130448],[-2362,-1562],[-19,1204],[2381,358]],[[10618,245580],[-434,-1826],[-1428,1329],[1862,497]],[[23739,421499],[-1273,266],[-371,856],[1226,-132],[418,-990]],[[21295,424942],[302,-2008],[-863,75],[-674,1628],[1235,305]],[[330522,564757],[-2484,-386],[1130,1182],[96,1602],[-474,950],[1994,835],[-322,-1010],[60,-3173]],[[849139,563455],[-543,1210],[478,1904],[65,-3114]],[[789066,566277],[-128,-2142],[-468,2025],[596,117]],[[322640,570662],[-189,-1442],[-1347,557],[1536,885]],[[756952,567249],[-417,1163],[437,867],[-20,-2030]],[[846040,571919],[987,176],[231,-3479],[667,-3091],[-738,614],[108,-1981],[-683,799],[17,3634],[-948,842],[-319,3624],[678,-1138]],[[840266,573530],[1840,-471],[-394,-2483],[-685,-1734],[-1590,-1190],[-581,-975],[-68,2449],[388,4569],[-389,1507],[1479,-1672]],[[847887,578938],[822,-1994],[-83,-3834],[551,-2248],[-1307,-116],[-879,2448],[-91,1286],[-1388,2776],[-251,1928],[2626,-246]],[[273324,548160],[-691,714],[342,994],[349,-1708]],[[465204,548773],[-1181,797],[934,393],[247,-1190]],[[825863,554717],[108,1623],[1486,3123],[639,628],[2525,5965],[731,1306],[-72,1607],[667,2969],[71,-2329],[438,-2379],[-1110,-1777],[-260,-1130],[-1136,-858],[-965,-3911],[-1237,-2245],[-1885,-2592]],[[846093,562701],[-650,-930],[-1178,-37],[-329,1146],[988,1883],[1123,-643],[46,-1419]],[[842029,558417],[-1578,2481],[-421,1250],[167,1586],[1068,743],[-108,2469],[462,2268],[760,636],[863,-1263],[-1126,-5430],[406,-3005],[-493,-1735]],[[842694,560701],[45,3066],[903,3001],[909,4739],[35,-4076],[-275,-1594],[-858,-1756],[-759,-3380]],[[65314,628731],[1381,-1039]],[[66695,627692],[-1247,-825],[-134,1864]],[[63295,630407],[1393,-358]],[[64688,630049],[-410,-585]],[[64278,629464],[-983,943]],[[297147,630270],[-382,-1263],[-1379,-247],[383,1501],[1378,9]],[[61668,631836],[456,-883]],[[62124,630953],[-1320,65]],[[60804,631018],[-453,1580]],[[60351,632598],[863,688]],[[61214,633286],[454,-1450]],[[67829,617353],[-833,346],[-465,4026],[635,1565]],[[67166,623290],[-33,1550]],[[67133,624840],[1759,-1667],[1095,-2784]],[[69987,620389],[-1404,-1566]],[[68583,618823],[-754,-1470]],[[663116,624502],[-224,703],[675,2033],[185,-974],[-636,-1762]],[[833610,576804],[595,-920],[-843,-25],[248,945]],[[329773,595059],[-554,1636],[447,355],[107,-1991]],[[434877,593631],[-948,408],[71,1386],[877,-1794]],[[328918,599549],[-472,327],[31,1737],[544,-500],[-103,-1564]],[[436338,600914],[626,-404],[-355,-1084],[-271,1488]],[[329646,600873],[-541,-14],[141,1651],[400,-1637]],[[430083,605116],[-464,856],[993,22],[-529,-878]],[[433089,603196],[-652,-766],[-151,1094],[803,-328]],[[331038,590589],[-1093,1836],[836,-409],[257,-1427]],[[902059,583271],[-257,1004],[809,839],[-552,-1843]],[[843655,577513],[901,-1899],[14,-1270],[-1429,2631],[-1037,-1605],[218,3897],[1333,-1754]],[[839149,577913],[-474,-140],[616,1904],[-142,-1764]],[[649342,579583],[1178,161],[899,-659],[-1060,-1138],[-1475,-109],[-785,1131],[609,1082],[634,-468]],[[757562,573062],[-524,1998],[447,2022],[451,6775],[570,1110],[32,-1738],[-521,-1836],[288,-2392],[-743,-5939]],[[835289,584576],[1384,-280],[889,-1784],[50,-2921],[-845,-2484],[-874,1735],[-439,2714],[-1007,3242],[842,-222]],[[838651,584936],[555,-457],[-304,-1530],[-488,733],[237,1254]],[[845425,585481],[-874,183],[517,2455],[534,-1224],[-177,-1414]],[[836392,615002],[2067,-1895],[836,1133],[426,-497],[-414,-3828],[328,-2141],[695,-1602],[-1067,-5569],[-1499,-1490],[32,-1561],[-596,-2046],[842,-3478],[-130,-1516],[422,-2178],[1142,-1088],[-33,1291],[808,1032],[1015,-424],[1043,-2982],[247,1862],[1492,-1553],[-855,-911],[748,-2230],[-95,-941],[994,-443],[-230,-2777],[-507,727],[198,1343],[-1771,756],[-411,2356],[-1578,2760],[-353,-124],[575,-3753],[-1673,3171],[-819,884],[-1595,-1762],[-1008,1448],[-567,-475],[-55,2272],[847,1808],[-94,1319],[-846,980],[15,-2358],[-417,-177],[-991,2357],[-529,5845],[-340,1011],[170,1885],[915,-1652],[638,1030],[-234,1823],[287,2526],[-139,4044],[669,5152],[1395,636]],[[285385,614067],[2532,-1810],[386,-1412],[-872,-280],[-912,637],[-978,-1534],[-1564,963],[-848,1859],[-613,159],[215,1363],[954,439],[1700,-384]],[[300613,621536],[615,1050],[1677,110],[2291,-1646],[479,212],[604,-2208],[1151,169],[-829,-1243],[2608,-1262],[960,-1738],[-1061,-2329],[-594,1123],[-2322,211],[-1145,-1136],[-1283,500],[-1064,-373],[-1022,-3715],[-1035,2328]],[[300643,611589],[-810,1122],[-2268,-455],[-1413,589],[-1388,-1240],[-1484,1803],[532,1875],[1767,-831],[2228,-519],[1227,1423],[-1287,2350],[299,2189],[-1925,1289],[774,1452],[1335,-17],[1159,-926],[1224,-157]],[[316307,613993],[1390,-376],[23,-824],[-973,-1588],[-3405,118],[106,2992],[2859,-322]],[[808024,623158],[347,-1993],[-533,-578],[-694,-2287],[-336,-2512],[-2588,-3138],[-2272,1878],[-183,2207],[161,2551],[1619,2505],[107,849],[3787,1378],[585,-860]],[[782522,517031],[-293,-2149],[-423,2086],[716,63]],[[856816,516873],[-656,1455],[1067,1778],[59,-2210],[-470,-1023]],[[767954,518699],[-1820,1751],[-7,1543],[1698,-2375],[129,-919]],[[852267,528613],[-164,3048],[455,-1522],[-291,-1526]],[[524265,526983],[585,-775],[-673,-2393],[-637,243],[725,2925]],[[800877,526576],[-599,88],[-269,2003],[551,936],[531,-1270],[-214,-1757]],[[722171,562852],[753,-97],[1273,-2547],[1838,-5539],[175,-1852],[1217,-4921],[-35,-2293],[-622,-2820],[-715,-1092],[-1822,-1551],[-1270,182],[-477,849],[-655,4006],[-421,7325],[280,-93],[712,6195],[58,2856],[-289,1392]],[[839146,542801],[-790,1099],[694,752],[638,-603],[-542,-1248]],[[836553,540712],[645,-436],[-1280,-627],[635,1063]],[[760805,545187],[-572,2089],[486,138],[86,-2227]],[[850016,559939],[520,-263],[351,-2559],[-495,-1288],[621,-850],[195,-3857],[375,-921],[32,-2545],[-1083,-2341],[-7,-3217],[-1014,6065],[-1176,-3185],[520,-1955],[222,-2886],[-1056,-2052],[-54,2375],[-647,-963],[-2285,2149],[-375,1014],[-257,3491],[615,2386],[-662,1589],[-1321,849],[-283,-2372],[-818,1735],[-704,-1014],[-143,1144],[-816,-294],[-894,-3961],[-589,-213],[466,4990],[570,1291],[1832,1138],[59,1054],[1158,1806],[1152,-1603],[-267,-2218],[1235,1015],[704,2232],[778,-257],[381,2425],[757,-613],[191,938],[803,-73],[-236,3877],[297,533],[1348,-2596]],[[293373,191181],[2597,-1316],[-2276,373],[-321,943]],[[297435,187868],[1994,-1930],[-993,-1578],[-1099,46],[541,1191],[-1503,-469],[-26,1273],[-1476,1087],[1107,804],[1455,-424]],[[309361,192779],[809,-1466],[-416,-2138],[909,-269],[424,-1527],[1984,-2877],[2941,-2866],[2935,-857],[-464,-1184],[-3236,-913],[-1165,634],[-3583,637],[-1203,-214]],[[309296,179739],[-2314,-31],[-659,870],[-2149,-578],[-3916,1864],[3356,866],[-469,2753],[934,1521],[421,-2130],[-694,-110],[1280,-2214],[1186,435],[1358,-1491],[580,893],[-2622,1763],[-446,2062],[2212,1665],[-790,864],[-1260,-497],[-1027,1255],[2672,4235],[920,-1043],[1492,88]],[[291370,215386],[-1109,-2334],[-314,2042],[1423,292]],[[292868,216836],[-988,-204],[-764,3862],[1030,736],[722,-4394]],[[692179,213769],[973,802],[366,-1722],[1815,1223],[654,-847],[-471,-1377],[-1307,505],[-376,-838],[1464,-553],[-645,-741],[-2561,1059],[-1028,-720],[-37,3468],[799,2426],[354,-2685]],[[290249,215820],[-391,1050],[251,3053],[470,303],[647,-3572],[-977,-834]],[[291514,206740],[-1095,-280],[340,1964],[1252,-581],[-497,-1103]],[[293121,213543],[-329,-5088],[-1015,2806],[-294,-1892],[-1344,362],[1288,3087],[-272,1106],[1085,2241],[630,-293],[251,-2329]],[[293575,234020],[-1047,171],[1054,2902],[-7,-3073]],[[295179,241703],[-688,-593],[711,-3699],[-1033,-1222],[-1441,4013],[1446,1532],[547,1508],[458,-1539]],[[297260,239419],[-1010,-314],[-264,1138],[659,1814],[1232,-1269],[-617,-1369]],[[967067,227084],[268,-1231],[-1997,-1118],[728,3310],[1001,-961]],[[911110,269175],[825,-1352],[-531,-1637],[-941,2321],[647,668]],[[899799,267051],[58,3154],[534,-2192],[-592,-962]],[[882212,292985],[1250,-98],[-1663,-1893],[-1925,247],[-323,1684],[1931,926],[730,-866]],[[980875,260159],[1729,1721],[1569,-181],[-648,-2429],[594,-1843],[-2050,-4606],[-900,-2717],[-1351,-2240],[919,-3080],[-2461,-127],[-2048,-1421],[-856,-4987],[-1205,-4186],[211,-1106],[-992,-415],[-2036,-3618],[-1633,-468],[-1990,150],[-536,1439],[-1408,1004],[-2641,-29],[-607,2288],[1326,1658],[-712,-70],[810,2487],[3725,6172],[1222,534],[5375,6307],[1419,3113],[650,3597],[1456,2073],[358,2948],[1392,2542],[967,-1955],[352,-2555]],[[295074,247915],[-1706,677],[494,2092],[480,6418],[1414,-598],[158,-3377],[-884,-707],[980,-2078],[-936,-2427]],[[902895,263078],[668,100],[2874,-2332],[1867,1014],[1292,-55],[1427,1315],[901,-992],[24,-6475],[-244,415],[-804,-3569],[157,-3464],[-542,-373],[-590,2218],[-628,-479],[-1315,-4065],[-904,615],[-1403,-227],[-136,1013],[-1408,2663],[-802,4122],[882,-732],[-1669,4208],[-748,3930],[201,1828],[900,-678]],[[981302,297748],[1597,-542],[1323,-1306],[723,-3193],[-526,70],[1142,-3174],[-222,-3150],[1603,-901],[675,-1233],[-227,4300],[1154,-2856],[449,-3809],[2034,-1712],[1572,-600],[1870,2583],[1464,-813],[-746,-5090],[-810,-1013],[-187,-3065],[-1392,939],[-1260,-1697],[433,-1811],[-744,-2871],[-2387,-6253],[-1869,-2354],[-401,1145],[-1473,758],[1465,3956],[254,1969],[-680,1997],[-2986,2625],[-356,2012],[1644,1226],[554,1052],[327,3314],[591,2495],[-950,4187],[-1103,3587],[591,-649],[-25,2144],[-1320,1314],[77,-934],[-2301,5750],[205,1120],[-1346,3324],[716,-474],[848,-2367]],[[989070,274777],[-712,800],[-39,-1605],[751,805]],[[814396,350367],[-607,1599],[50,1558],[557,-3157]],[[925214,352158],[-109,3303],[566,1604],[301,-833],[-758,-4074]],[[970372,392507],[-965,408],[435,1423],[530,-1831]],[[956117,384770],[648,-214],[2100,-2884],[1308,-2951],[1780,-2193],[1775,-2683],[-468,-1694],[-1659,1700],[-224,785],[-2372,2554],[-874,1396],[-2105,4796],[91,1388]],[[654992,378293],[-1207,389],[-143,2190],[974,-13],[376,-2566]],[[965001,379352],[-1022,1410],[476,1475],[546,-2885]],[[660142,383355],[-745,-111],[7,1630],[752,1414],[377,-1319],[-391,-1614]],[[995222,401798],[865,-1656],[211,-2544],[-1411,-1003],[-868,-28],[-1461,1051],[-159,1268],[983,2383],[1840,529]],[[85218,399912],[-715,-265],[-90,1205],[805,-940]],[[967903,400788],[220,-1515],[-1018,519],[798,996]],[[967490,407932],[-1022,639],[653,871],[369,-1510]],[[999997,408927],[-1056,-2128],[-142,-1301],[1007,1350],[-8,-1333],[-1412,-367],[-603,556],[-1377,-1561],[-581,1115],[2929,3187],[1243,482]],[[967907,405311],[-737,-148],[49,1245],[688,-1097]],[[887521,406531],[-968,-999],[370,1625],[598,-626]],[[292158,198837],[809,-2144],[-889,-1651],[-704,2853],[784,942]],[[336527,200971],[2427,-678],[384,-1926],[-2348,-1344],[85,-965],[-1508,483],[-555,-1720],[-487,2267],[1420,1421],[582,2462]],[[332538,199833],[2828,204],[-1815,-3211],[-2125,-1297],[-764,777],[2009,1690],[-917,2454],[784,-617]],[[937461,461289],[-1069,40],[-1789,3604],[61,629],[1731,-2063],[1066,-2210]],[[816234,462622],[-1038,-670],[-1196,39],[-873,824],[395,1024],[3074,159],[-362,-1376]],[[609712,468136],[213,-1261],[-916,666],[-142,2038],[324,1239],[521,-2682]],[[873713,466550],[-503,-2207],[-824,269],[302,3514],[1025,-1576]],[[359296,503937],[-758,-1160],[-191,1316],[949,-156]],[[840680,473499],[-1007,-298],[240,3272],[925,881],[161,-1868],[-319,-1987]],[[856320,482947],[-826,-571],[106,1008],[720,-437]],[[852391,486423],[1017,-1796],[7,-1434],[-1509,-1129],[-1412,1447],[-421,2423],[2318,489]],[[860429,487739],[1734,-732],[1336,-3442],[-152,-1703],[-2182,2269],[-489,875],[-1044,-747],[-1990,904],[-923,-684],[-798,1532],[-587,-2065],[-118,1682],[890,2109],[3414,447],[909,-445]],[[924903,476538],[-792,1528],[-227,2983],[-1129,2898],[-3795,4698],[-2,829],[3762,-4961],[2325,-4120],[322,-1463],[-464,-2392]],[[823064,481800],[-583,-1101],[-209,2098],[275,2135],[424,524],[93,-3656]],[[933215,465101],[-662,-1042],[-1043,836],[-394,2453],[-1167,1996],[-146,3118],[1012,-1043],[1036,-3108],[989,-1396],[375,-1814]],[[874296,470906],[25,-2747],[-874,-973],[-800,1869],[1286,3441],[363,-1590]],[[929575,472530],[-298,1907],[520,-634],[-222,-1273]],[[911181,470199],[-679,1180],[820,-136],[-141,-1044]],[[869239,469751],[523,3503],[14,-1573],[-537,-1930]],[[610736,475651],[-34,-2071],[-573,-669],[74,2616],[533,124]],[[839007,472500],[-651,1077],[437,1070],[214,-2147]],[[921986,479261],[561,501],[801,-761],[-16,-2314],[-395,-1325],[-679,-290],[360,-2092],[-771,-1232],[-973,73],[-794,-2176],[-2224,-2112],[-2155,-83],[-751,1258],[-710,-295],[-2015,2149],[-157,1304],[1817,357],[683,-523],[1994,742],[229,2446],[170,-2335],[535,-632],[1817,662],[1038,2747],[957,456],[-353,3461],[1031,14]],[[842164,477754],[-552,-3760],[615,-520],[-1083,-2356],[-631,750],[504,1982],[501,4547],[646,-643]],[[917878,488947],[-752,10],[-567,1104],[738,531],[581,-1645]],[[277412,487103],[-256,1303],[874,163],[-618,-1466]],[[908520,493104],[924,-385],[-538,-929],[-1833,-158],[305,1390],[1142,82]],[[778958,485860],[-614,1162],[115,1206],[499,-2368]],[[800575,486958],[-639,-1329],[-331,803],[-677,-729],[143,3810],[1135,-182],[600,-1380],[-231,-993]],[[778344,488478],[-525,-466],[-9,1833],[534,-1367]],[[777341,490836],[-656,507],[221,1150],[435,-1657]],[[850152,490195],[-534,2216],[361,387],[173,-2603]],[[876315,495287],[2021,-375],[1920,-857],[-1845,-557],[-2058,1335],[-38,454]],[[862092,494702],[182,-1665],[-475,-452],[-1418,1072],[1711,1045]],[[850066,494114],[854,-196],[-2497,-684],[245,814],[1398,66]],[[847137,494613],[974,-620],[-2507,-1155],[-103,1882],[1636,-107]],[[855980,494879],[-1643,-402],[-471,495],[707,1851],[1407,-1944]],[[794571,494827],[889,-4713],[1257,-643],[-574,-1908],[153,-1045],[-1856,1463],[-591,3813],[-1814,822],[1257,3056],[901,129],[378,-974]],[[846913,510614],[-1282,-3109],[-1872,-978],[-1356,125],[-508,943],[-3446,-291],[-1156,345],[-1147,-315],[-868,432],[-976,-388],[-616,-1674],[-317,-2150],[234,-2688],[1167,-2307],[416,-1959],[1018,-216],[1349,3264],[1251,-460],[862,1044],[1691,11],[785,1093],[579,-461],[-5,-2107],[-913,781],[-672,-556],[-836,-2261],[-2188,-3051],[-1029,-493],[1389,-2284],[1528,-5150],[-404,-2487],[1734,-2894],[56,-1422],[-2176,-1132],[-113,-1490],[-1347,190],[-283,1057],[365,2893],[-1956,3181],[390,2304],[-178,2943],[-935,15],[-1110,-2282],[507,-3877],[-206,-2242],[160,-3149],[-391,-3133],[419,-2636],[-1329,80],[-651,-686],[-948,1591],[655,5931],[33,2307],[-566,3311],[-1181,-368],[-506,2257],[-80,2320],[857,1671],[638,3278],[-36,3089],[552,2971],[566,1339],[369,-1073],[-340,4582],[929,4627],[738,1722],[541,-982],[1098,2793],[1467,-441],[421,-868],[2347,-295],[1265,-996],[1071,462],[1581,-532],[1186,1090],[1988,4022],[679,-1179],[-958,-3002]],[[842256,497778],[70,-1292],[859,308],[-860,-1420],[-241,1615],[-723,-1675],[49,2398],[846,66]],[[246466,504866],[1312,-4604],[-294,-1117],[-1294,-453],[-241,1287],[937,1426],[-690,1611],[270,1850]],[[775454,494184],[-804,677],[-757,2759],[742,1672],[1106,-4219],[-287,-889]],[[804750,497722],[-785,-359],[134,1516],[651,-1157]],[[876063,500858],[1418,-441],[1338,-2182],[-736,-728],[-754,578],[-1266,2773]],[[863893,496923],[-610,356],[-303,1752],[1113,-50],[-200,-2058]],[[356020,496222],[796,4646],[811,642],[259,-743],[-321,-2103],[-1545,-2442]],[[362142,503359],[1422,389],[1469,-403],[579,-718],[-445,-2655],[-1081,-4037],[-677,412],[-170,-1107],[-758,522],[-829,-1652],[-1952,14],[-765,4607],[383,4370],[1103,926],[1721,-668]],[[249070,500146],[-577,563],[758,1137],[-181,-1700]],[[854352,502828],[105,-1727],[765,-1175],[-1161,15],[-381,2786],[672,101]],[[790205,502734],[322,-781],[-630,-1138],[-295,1157],[603,762]],[[863369,504693],[1288,-863],[-54,-1280],[-700,30],[-1065,1628],[185,-1249],[-1255,516],[161,640],[1440,578]],[[943750,449782],[612,-952],[1066,69],[1290,-2614],[-2681,423],[-644,1535],[357,1539]],[[918134,449344],[722,-423],[293,-1478],[-1271,254],[256,1647]],[[917625,448471],[-654,782],[584,579],[70,-1361]],[[919668,445353],[599,376],[-182,-1410],[-755,605],[-508,2272],[846,-1843]],[[833367,449176],[774,-1617],[591,-155],[913,-2154],[-1093,-1519],[-817,556],[-1511,2527],[-1433,394],[-351,1111],[630,800],[2297,57]],[[923974,451638],[933,-1480],[-685,253],[-248,1227]],[[946525,455461],[690,-1766],[-148,-1107],[595,-965],[453,-3719],[-1245,2564],[-770,4909],[425,84]],[[847805,449005],[-589,-957]],[[847216,448048],[-1585,-3456],[-1583,-1156],[-593,193],[-152,2040],[333,2085],[744,1433]],[[844380,449187],[613,692]],[[844993,449879],[1049,597]],[[846042,450476],[873,1108]],[[846915,451584],[381,648]],[[847296,452232],[420,1251],[1740,923],[2265,193],[963,852],[915,-645],[-1058,-1722],[-1479,-1436],[-2707,-1885],[-550,-758]],[[828450,455439],[2071,-118],[394,-1958],[-1809,-1116],[-277,1078],[-524,-983],[-3135,-1532],[-759,549],[130,2808],[916,979],[1117,-351],[664,-1687],[1191,707],[-1167,1480],[-165,1141],[1006,161],[347,-1158]],[[824001,453685],[-732,-1867],[-1396,611],[561,479],[-44,1822],[947,1382],[924,-1083],[-260,-1344]],[[937191,453094],[-491,880],[464,854],[27,-1734]],[[841063,453697],[-1913,-788],[-1230,-912],[-658,496],[-1054,-714],[-1346,792],[-1782,-330],[25,2443],[1923,1213],[2317,-1999],[1450,726],[823,-1005],[1988,2802],[76,-1050],[-619,-1674]],[[820688,456402],[712,-1494],[-1029,-1234],[-271,-1078],[-1095,2187],[-636,296],[-383,1535],[2702,-212]],[[844234,455707],[-1032,-1744],[-897,215],[1516,2015],[413,-486]],[[845239,455369],[-613,-1316],[-262,1170],[875,146]],[[846042,456487],[1526,-379],[-78,-877],[-1812,-544],[364,1800]],[[938231,455887],[155,-2192],[-726,2027],[-904,-266],[558,1956],[917,-1525]],[[944108,454156],[-2598,2924],[-1351,2939],[769,-353],[1042,-1774],[2038,-2508],[100,-1228]],[[885819,455018],[-923,472],[648,802],[275,-1274]],[[935243,457777],[-352,2068],[689,-882],[-337,-1186]],[[852224,459289],[-914,-1674],[-1010,394],[-861,-596],[492,1903],[661,-257],[1100,799],[532,-569]],[[884820,455700],[-665,-779],[-1690,-39],[-7,886],[895,3677],[800,1203],[1317,285],[610,-1811],[-564,-2151],[-696,-1271]],[[798260,469125],[814,-1233],[1745,-292],[1063,-3113],[4857,-929],[863,2814],[653,217],[506,-1382],[1072,123],[1520,-1452],[1255,-197],[709,-2239],[0,-1469],[1261,-982],[2284,505],[1038,-1556],[-159,-3019],[546,-2159],[-3695,2861],[-1596,-726],[-3247,617],[-2508,922],[-1579,1534],[-2103,1100],[-1501,224],[-804,-770],[-1484,432],[-1757,1495],[-2305,611],[178,1865],[-2877,1613],[843,1923],[226,2018],[574,1198],[2084,-1092],[614,1151],[910,-613]],[[864792,457323],[-664,798],[481,2337],[1065,2120],[-51,-3042],[-831,-2213]],[[890964,489271],[993,-25]],[[891957,489246],[3074,-2798],[1926,-1404],[1677,-655],[1409,-2089],[1283,-246],[1695,-3103],[685,-214],[1201,-2594],[-60,-3432],[1828,-1269],[1753,-1793],[951,-187],[1181,-2160],[121,-2056],[-2018,-351],[-440,-1227],[637,-2662],[1484,-2952],[1118,-1346],[334,-2670],[567,-832],[367,-2116],[1846,-114],[-124,-1990],[758,-1074],[1382,-431],[-589,-858],[313,-1227],[2203,-1447],[-614,-297],[558,-1248],[-909,-811],[-842,459],[-729,1329],[-3808,993],[-1707,683],[-1002,2343],[-1087,1699],[-147,1945],[-743,202],[-1842,5623],[-846,734],[-2097,891],[-304,1009],[-985,381],[-790,-1170],[-909,539],[123,-1601],[-1178,-336],[265,-1183],[-1441,-656],[-1074,231],[1120,-1198],[780,-1939],[-72,-943],[-1998,-2173],[-1160,934],[-3045,-303]],[[892036,450086],[-580,807]],[[891456,450893],[-2749,5829],[-1526,-521],[-1471,261],[645,3305],[-945,1989],[835,302],[-1245,1717],[734,310],[-1183,3050],[-396,2337],[-639,1618],[-15,1249],[-2698,3204],[-1307,626],[-1776,1705],[-2178,475],[-1226,1513],[-132,1425],[-1223,53],[-812,758],[-891,2687],[-1124,-4135],[-778,-193],[-596,2317],[322,905],[-329,1518],[-2167,2999],[721,641],[1373,-645],[1293,2081],[1161,-646],[822,925],[48,1712],[-2665,-1011],[-1819,180],[-789,1492],[-259,2552],[-1768,985],[-827,-187],[726,3374],[1519,898],[901,1479],[1379,565],[2355,-2176],[1394,-108],[757,-3354],[-393,-2432],[139,-2809],[845,-3775],[465,1751],[208,-2351],[392,145],[538,-2513],[1249,-70],[2102,4515],[407,1835],[1259,448],[910,1020],[-131,1094],[1895,2119],[2344,-1824],[3166,-3302],[2314,-577],[347,-956]],[[897718,433893],[-190,-2068],[809,-1993],[514,-4761],[-106,-1763],[577,-3600],[571,-676],[1420,1368],[486,-1543],[1777,-2670],[-45,-3161],[518,-3435],[-89,-2071],[1322,-3935],[622,-3347],[-260,-3778],[836,-1664],[-101,-1703],[512,-1408],[2604,-1773],[1381,-2909],[1902,-1635],[790,-1990],[-559,-587],[1448,-3229],[692,-2687],[394,-4022],[1052,-1736],[282,2288],[1291,-2338],[620,-101],[220,-5225],[1827,-3284],[1116,-1117],[630,-2350],[907,-1214],[551,-2367],[720,-1363],[18,-1520],[680,-1632],[-224,-2013],[91,-5276],[1274,-6198],[80,-3637],[-712,-2583],[-211,-3567],[-672,-3974],[-240,-5163],[-1068,-3619],[-247,-2331],[-1826,-2737],[-1448,-4028],[-32,-2048],[-889,-2194],[-750,-5218],[-341,-216],[-1034,-3669],[-653,-5995],[-76,-4047],[-1762,-1621],[-2878,-169],[-1071,-613],[-1337,-1688],[-1496,-2633],[-1568,-215],[484,-833],[-185,-1808],[-671,1658],[-2115,1957],[-290,1764],[-925,-1559],[445,2426],[-636,1134],[-1376,-1404],[748,-433],[-1565,-1495],[-1563,-2124],[-2575,2187],[-2463,1068],[-836,-546],[-1148,1698],[-1066,288],[-2342,4636],[204,3458],[-859,3350],[-1609,3057],[622,1383],[-1864,-1749],[-937,176],[907,3486],[-59,1545],[-1113,3518],[-1104,-5766],[-2245,-573],[363,1919],[1047,15],[285,4456],[1217,3448],[-221,2242],[390,631],[-560,1605],[-969,-2194],[-569,-2582],[-2241,-2373],[-1500,-3738],[300,-1676],[-975,25],[-1158,2132],[609,-8],[-735,3995],[-824,1661],[-271,1766],[-1361,967],[-558,2467],[372,1186],[-1897,2166],[-942,-5],[-1263,1348],[-1508,-302],[-1371,1842],[-1604,1188],[-1002,-641],[-1814,147],[-3288,-732],[-2440,-2155],[-2078,-1171],[-3896,-195],[-3219,-3470],[-1756,-1461],[-1322,-4189],[-1229,-900],[-1195,578],[-1741,-599],[-249,696],[-1823,282],[-2740,-808],[-1568,-68],[-1121,-2332],[-1542,-661],[-2111,-3003],[-1537,-658],[-2959,651],[-1472,1143],[-724,1593],[-1994,1601],[-40,4387],[521,-759],[927,664],[466,2005],[43,8877],[-1449,5252],[-507,3507],[-98,4636],[-919,3329],[-252,1948],[-1035,2739],[-380,4344],[-885,2960],[-1046,2550],[156,1847],[535,-2681],[753,1339],[-733,1383],[-538,2283],[883,-696],[1048,-3335],[348,617],[-4,2595],[-1510,5181],[-703,3207],[376,4164],[567,1864],[105,2338],[-311,2286],[765,4139],[459,654],[50,-3877],[655,839],[626,2366],[712,1222],[1659,1447],[1541,2733],[1933,2231],[1943,-400],[2202,2049],[1534,671],[981,1581],[1337,-255],[1695,763],[1896,1448],[837,1109],[871,2201],[945,3729],[1465,2606],[-344,406],[-214,3880],[755,2034],[801,1082],[695,2079],[476,-2525],[1169,-3898],[153,3037],[445,832],[-800,2235],[664,1766],[361,-1124],[752,613],[902,-334],[340,1312],[-542,2106],[161,1568],[863,1234],[868,-930],[-622,1668],[650,761],[785,-519],[-491,2400],[1114,1372],[863,-798],[815,3649],[1071,-942],[927,2470],[2137,-2672],[1464,-3298],[-361,-3423],[514,184],[-221,1513],[840,1511],[1613,-571],[438,-1634],[143,1711],[1021,-1590],[6,1711],[588,130],[-417,1503],[-889,1084],[920,2443],[359,2412],[1169,1604],[-255,2042],[1262,3119],[681,-752],[18,1130],[1160,1774],[407,-1239],[2264,538],[439,-646],[608,1540],[123,2288],[-553,933],[-949,-55],[-894,1359],[1253,399],[1166,-1786],[951,312],[445,-1498],[1997,-748],[924,-1041],[1371,137],[1355,-1405],[1957,2345],[-611,-1929],[653,-4],[897,-1668],[26,1789],[751,1031],[1131,-2324],[-1140,-2574],[-212,-2612],[-463,518],[-1019,-986],[173,-2997],[-295,-2031],[-1328,-3585],[348,-1436],[1874,-2387],[239,-987],[1148,-683],[2775,-3245],[1504,-2875],[2125,-1072],[662,-2544],[2188,-2215],[1320,462],[886,1245],[377,2369],[703,2183],[536,3416],[110,2750],[483,3251],[-286,3475],[200,1879],[-339,2105],[481,3189],[-89,1871],[852,832],[-644,2678],[730,2694],[603,5627],[800,1417],[1057,-3552],[99,-3048],[851,-789]],[[858420,409078],[-1074,-97],[-319,-2064],[567,-1663],[189,2004],[637,1820]],[[637606,431064],[1109,-3794],[656,-5734],[171,-4098],[687,-3872],[-760,-3406],[-879,2979],[-674,-648],[174,-3021],[329,-1060],[-177,-3313],[-633,-1291],[-284,-1859],[113,-3269],[-2419,-15161],[-712,-5281],[-1229,-6617],[-973,-8346],[-1057,-5407],[-1247,-2148],[-1583,-477],[-1807,-1972],[-1092,119],[-839,1238],[-1298,640],[-862,1365],[-966,3779],[-115,3649],[211,1258],[-901,3811],[-365,4959],[654,4105],[829,1050],[308,1857],[912,2880],[459,2711],[122,2923],[-583,2094],[-16,1982],[-536,2678],[-169,5314],[1228,4081],[152,2877],[1203,253],[716,1135],[1198,-57],[283,1058],[1754,594],[1529,2868],[418,-1144],[167,1615],[963,2647],[237,-1712],[797,2650],[-106,1037],[617,2426],[123,2158],[599,-730],[1503,2678],[316,1964],[-344,2755],[1169,2318],[920,-2088]],[[965034,409358],[1178,-2096],[-1076,-624],[-694,3969],[592,-1249]],[[963182,416876],[179,-1958],[737,1314],[344,-3259],[-1226,-862],[-641,4627],[607,138]],[[623545,433141],[79,-1630],[-791,1097],[712,533]],[[879762,422936],[499,-2898],[-1552,482],[248,2055],[805,361]],[[620738,434208],[-665,886],[462,2033],[203,-2919]],[[946044,434821],[-1591,1293],[-9,638],[1600,-1931]],[[862386,435524],[408,-814],[-1319,-47],[60,2056],[517,832],[334,-2027]],[[862828,437320],[1124,247],[681,856],[751,-1463],[-222,-895],[-1411,-2006],[-1219,1829],[-398,2387],[694,-955]],[[926487,436727],[-435,-494],[-488,1395],[923,-901]],[[841524,440086],[-287,873],[1460,1700],[130,-1045],[-1303,-1528]],[[949208,443178],[1084,-394],[743,-2189],[-694,-7],[-1626,1529],[-647,2145],[1140,-1084]],[[362655,504051],[-1073,110],[1194,895],[-121,-1005]],[[791051,503675],[-922,-79],[267,1226],[655,-1147]],[[361838,506306],[-280,-1548],[-1390,216],[185,1116],[1485,216]],[[518498,505431],[-531,633],[607,1049],[-76,-1682]],[[359927,505541],[-549,-502],[758,3125],[-209,-2623]],[[770781,513396],[1251,-2909],[-154,-2047],[-534,-192],[-1680,4913],[1117,235]],[[786187,509140],[-1594,847],[265,1426],[1329,-2273]],[[786573,509872],[-1291,1088],[245,662],[1046,-1750]],[[790514,511922],[-28,-2276],[-593,2071],[621,205]],[[784519,510583],[-552,2119],[596,-673],[-44,-1446]],[[784699,513362],[21,-760],[-1169,993],[-101,752],[1249,-985]],[[768033,535698],[974,267],[1957,-406],[1002,-1931],[945,-2757],[164,-1906],[3958,-5390],[2014,-5485],[1195,-1831],[-164,1744],[605,87],[1196,-3342],[856,-425],[1034,-2148],[867,-2841],[1056,-378],[602,-1324],[-1434,-1633],[2019,1648],[562,-85],[855,-2567],[-995,-1414],[7,-2025],[805,-2092],[1777,-899],[430,-4627],[916,-1621],[-491,-1733],[187,-1098],[806,1264],[1545,-797],[1284,-3639],[-397,-1800],[81,-2506],[-277,-1954],[156,-5016],[-197,-3951],[-549,-729],[-748,1481],[-744,-1161],[-1228,1334],[-105,-2276],[-2140,4887],[-2534,3608],[-1060,1887],[-1138,3276],[-1525,2560],[-1278,3432],[-751,2629],[20,1242],[-1025,3763],[-495,2800],[-1245,3038],[-729,2466],[-1219,1477],[-1006,6771],[-645,2414],[-2399,2703],[-305,2893],[-554,762],[-1174,3554],[-1456,1429],[-2639,5599],[-801,3096],[527,2043],[1237,-678],[811,-1304],[997,-385]],[[854812,509742],[412,-95],[768,2870],[1475,1516],[39,-2761],[-1121,-1360],[-106,-849],[974,-1088],[801,-1977],[-2546,1514],[-267,-1027],[251,-3239],[767,-2863],[-576,151],[-985,2750],[48,3140],[-426,1194],[145,2124],[-521,2392],[588,3506],[1124,2105],[-416,-2169],[347,-2969],[-997,-1883],[222,-982]],[[826676,529600],[-104,-224]],[[826572,529376],[-280,-510],[866,-2292],[-1682,-298],[502,-2638],[716,-766],[1268,-4423],[-771,-1724],[809,-1926],[1551,-2268],[961,-1995],[-1250,-999],[-940,360],[-792,1329],[33,-1584],[-494,-602],[-619,-2925],[-165,-3316],[277,-2649],[-895,-918],[-2680,-5089],[413,0],[292,-4300],[-491,-65],[-265,-3584],[-836,-2775],[-3508,-3405],[-466,4698],[-221,-623],[-1011,1202],[-795,-1050],[-516,1543],[-742,-301],[-859,1855],[-174,-1503],[-1030,-1264],[-876,471],[-1286,-1253],[2,2816],[-1264,732],[-1216,-814],[-989,1064],[-754,-556],[-633,6154],[-319,496],[165,2749],[-644,2296],[-1434,1654],[-415,2767],[377,1755],[-869,1922],[-108,2597],[472,4157],[841,2529],[696,622]],[[804524,516729],[987,-1836],[1014,12],[2081,-1888],[471,4377],[-72,1754],[559,-322],[0,1497],[790,1301],[2804,1284],[854,797],[1115,3173],[1328,2978],[360,2071]],[[816815,531927],[345,-11]],[[817160,531916],[1018,792],[1253,1764],[87,-727]],[[819518,533745],[315,0]],[[819833,533745],[652,196],[575,1549],[-452,1297],[513,1127],[536,-398],[949,3515],[990,2323],[708,2699],[454,-1881],[598,1832],[459,-1730],[978,-1204],[-79,-3157],[1074,667],[310,-1131],[1331,-1602],[1746,-1063],[-253,-1849],[-1278,-809],[-1143,147],[-209,-950],[1046,-1933],[-178,-828],[-1360,-665],[-742,518],[-382,-815]],[[190458,968527],[-4765,717],[7618,2063],[2826,-2488],[-5679,-292]],[[232764,969972],[3660,-1102],[-556,-2089],[-5284,-1107]],[[230584,965674],[-3516,3693]],[[227068,969367],[120,2224]],[[227188,971591],[5576,-1619]],[[785790,974253],[-1312,-2479],[3501,1864],[4092,-1962],[463,-1890],[-4426,-1432],[-6987,-393],[-3116,-1285],[-2208,374],[4412,4417],[890,2493],[3018,1337],[1673,-1044]],[[771316,979611],[-62,-2356],[2624,1727],[4069,-1630],[-1727,-5585],[-5233,-46],[-7679,1540],[-1590,1871],[-3189,551],[5324,3564],[7463,364]],[[215066,972034],[2424,1181],[5817,-2936],[-394,-1660]],[[222913,968619],[1625,-2642]],[[224538,965977],[-3944,205]],[[220594,966182],[-1356,1790],[-4603,1051]],[[214635,969023],[-4425,-602],[-1865,1475]],[[208345,969896],[3420,6]],[[211765,969902],[-1076,2786]],[[210689,972688],[-1849,-1082]],[[208840,971606],[-1995,1335],[411,1725]],[[207256,974666],[6871,-549],[939,-2083]],[[244762,985385],[3451,-3194]],[[248213,982191],[8245,-1313]],[[256458,980878],[-687,-1627]],[[255771,979251],[2624,-1204],[-882,-1861]],[[257513,976186],[4576,185]],[[262089,976371],[995,-2388]],[[263084,973983],[-6467,-3153]],[[256617,970830],[-3122,-546]],[[253495,970284],[-137,-2320]],[[253358,967964],[-3461,2456]],[[249897,970420],[1472,-2391]],[[251369,968029],[-6645,199]],[[244724,968228],[-6288,4486]],[[238436,972714],[7831,2173]],[[246267,974887],[-4106,527]],[[242161,975414],[-6338,-948]],[[235823,974466],[-4639,5012]],[[231184,979478],[5909,-516],[-4783,1448]],[[232310,980410],[2276,435]],[[234586,980845],[-1622,1924],[6125,-783]],[[239089,981986],[-4409,1652]],[[234680,983638],[680,964],[7938,1644]],[[243298,986246],[1464,-861]],[[451076,977642],[-3846,679],[4238,522],[-392,-1201]],[[225579,978561],[-137,-1445],[-3477,1075]],[[221965,978191],[1004,1336],[2610,-966]],[[757453,976808],[-4327,1302],[834,855],[6603,-857],[-3110,-1300]],[[658551,980752],[4,-1518],[-3767,60],[3763,1458]],[[375376,991018],[-5243,1567],[980,2038],[4581,-1724],[-318,-1881]],[[558049,980153],[3866,-1188],[5578,1843],[7120,-1187],[938,-1501],[-4326,-2983],[-4704,-1237],[-3006,1289],[-5568,-83],[-3296,1145],[3082,933],[-5720,72],[-1304,998],[3022,1109],[753,2051],[3565,-1261]],[[639661,984167],[3960,-1420],[-7799,-1886],[226,-1224],[-3736,-300],[-2745,1116],[10094,3714]],[[768129,985046],[3624,-1644],[-1824,-3301],[-9173,-1369],[-6524,2065],[4830,2564],[-548,1168],[7598,1730],[2017,-1213]],[[660989,979403],[-2448,2197],[3904,-173],[-1456,-2024]],[[631783,983731],[-3613,-2411],[-3435,975],[7048,1436]],[[672688,983619],[-3102,-2466],[-4852,610],[799,1748],[7155,108]],[[651996,985285],[8265,-1918],[-5505,-918],[-4572,1045],[1812,1791]],[[676038,982821],[-2371,721],[6338,2224],[1765,-1579],[-5732,-1366]],[[662839,984844],[660,-1553],[-4580,1408],[3920,145]],[[660583,987833],[-998,-2432],[-4817,313],[5815,2119]],[[57298,634654],[-1158,649],[1215,1053]],[[57355,636356],[-57,-1702]],[[270661,632517],[-809,-757],[-637,2059],[1022,586],[424,-1888]],[[272673,641945],[1831,-612],[1468,257],[2704,-2133],[1113,-1987],[1637,-242],[2916,-3373],[388,440],[2360,-3479],[2921,-1717],[-130,-1547],[2112,-491],[1026,-1577],[960,-547],[-237,-1259],[-2883,-1105],[-2411,572],[-4324,-795],[449,1343],[1249,1927],[-349,1400],[-2132,424],[-1371,2005],[-405,2736],[-518,612],[-1017,-391],[-2003,1124],[-1636,1901],[-1285,-63],[-2374,873],[-726,1111],[891,468],[-407,1258],[-2318,61],[-1782,-2763],[-1448,-313],[-362,-1345],[-1310,-989],[490,1766],[-97,1805],[879,1701],[2186,1786],[3212,1321],[733,-163]],[[241809,926906],[-91,-2428]],[[241718,924478],[2771,-3358]],[[244489,921120],[20,-1462]],[[244509,919658],[-2531,-2195]],[[241978,917463],[2711,-811],[3769,-159]],[[248458,916493],[-1896,-1297]],[[246562,915196],[2137,-2499]],[[248699,912697],[-293,-2305]],[[248406,910392],[1025,-1287],[1495,4485]],[[250926,913590],[1694,1491],[2820,-2692],[523,-3228],[-1372,126],[420,-3095]],[[255011,906192],[2581,-3447]],[[257592,902745],[2028,1968]],[[259620,904713],[1623,3296]],[[261243,908009],[1207,4132]],[[262450,912141],[1821,1802],[-1457,935]],[[262814,914878],[-79,3659],[3260,-87]],[[265995,918450],[1601,-800]],[[267596,917650],[3587,-343],[-1058,-874]],[[270125,916433],[3962,-2218]],[[274087,914215],[-1748,-1400]],[[272339,912815],[1879,-1341]],[[274218,911474],[-3531,-1249]],[[270687,910225],[1600,-3463]],[[272287,906762],[1962,-2382]],[[274249,904380],[-548,-2311],[-3261,-2857]],[[270440,899212],[-2449,-1296]],[[267991,897916],[-1103,1838],[-2571,2071]],[[264317,901825],[2833,-4376]],[[267150,897449],[-1813,-656]],[[265337,896793],[-2677,2121]],[[262660,898914],[-3309,-35]],[[259351,898879],[1640,-3014],[-3468,-3956],[-1884,-35],[-4943,3478]],[[250696,895352],[-3500,176],[3393,-1357],[2261,-2301]],[[252850,891870],[5407,-890]],[[258257,890980],[-703,-2203]],[[257554,888777],[-2293,-3809]],[[255261,884968],[-1977,-1132]],[[253284,883836],[-3751,-80]],[[249533,883756],[-215,-1996],[-1945,-320],[-2489,650],[3041,-2050]],[[247925,880040],[-345,-2403],[-1605,-840]],[[245975,876797],[-2534,90],[981,-1652]],[[244422,875235],[-1510,36]],[[242912,875271],[66,-2240]],[[242978,873031],[-2928,-1341]],[[240050,871690],[750,-1036]],[[240800,870654],[-2080,-2662]],[[238720,867992],[-21,-1061],[-1607,-4281]],[[237092,862650],[-386,-2742],[-7,-4061]],[[236699,855847],[319,-2357]],[[237018,853490],[1073,-913]],[[238091,852577],[-125,-2480]],[[237966,850097],[767,2741]],[[238733,852838],[2161,-22]],[[240894,852816],[2329,-8777]],[[243223,844039],[-794,-2022]],[[242429,842017],[4484,1823]],[[246913,843840],[1442,-99]],[[248355,843741],[2226,-1441]],[[250581,842300],[2340,-771]],[[252921,841529],[2426,-2274]],[[255347,839255],[1427,-2435]],[[256774,836820],[5235,-2697]],[[262009,834123],[1710,-1869]],[[263719,832254],[3196,172]],[[266915,832426],[3703,-983]],[[270618,831443],[995,-1986],[-552,-2712],[768,-3188]],[[271829,823557],[-331,-5075]],[[271498,818482],[1914,-3518]],[[273412,814964],[61,-774]],[[273473,814190],[2477,-2833]],[[275950,811357],[499,-2672],[1041,-145]],[[277490,808540],[1081,-979]],[[278571,807561],[1044,3024],[828,-973]],[[280443,809612],[381,-1561]],[[280824,808051],[478,1760],[-685,1399]],[[280617,811210],[1350,3072]],[[281967,814282],[-644,2225]],[[281323,816507],[-1082,6455]],[[280241,822962],[469,729]],[[280710,823691],[-2003,5079],[2100,1089],[2829,2104],[1572,1889]],[[285208,833852],[1874,3270]],[[287082,837122],[363,3553]],[[287445,840675],[-148,2809]],[[287297,843484],[-1622,4963]],[[285675,848447],[-3773,3931]],[[281902,852378],[157,1131]],[[282059,853509],[1939,3002]],[[283998,856511],[96,1753]],[[284094,858264],[1096,715]],[[285190,858979],[55,1456]],[[285245,860435],[-1331,3540]],[[283914,863975],[522,1098]],[[284436,865073],[-1607,-36]],[[282829,865037],[1853,4366]],[[284682,869403],[-1203,1219]],[[283479,870622],[-335,3516]],[[283144,874138],[1932,1287]],[[285076,875425],[4321,-1521]],[[289397,873904],[3253,-620]],[[292650,873284],[2822,1440]],[[295472,874724],[3900,-3689]],[[299372,871035],[2231,-3985],[3176,-535],[511,-1116],[1732,774],[-927,-6315]],[[306095,859858],[629,-1599],[-285,-1975]],[[306439,856284],[783,-23]],[[307222,856261],[-366,-2776]],[[306856,853485],[2936,-271]],[[309792,853214],[669,-2514]],[[310461,850700],[1845,-1100]],[[312306,849600],[2672,1987]],[[314978,851587],[2782,3329]],[[317760,854916],[556,4055]],[[318316,858971],[1319,2706]],[[319635,861677],[1421,-477],[-969,-944]],[[320087,860256],[1347,308]],[[321434,860564],[2066,-4332]],[[323500,856232],[94,-1290]],[[323594,854942],[1756,-2623]],[[325350,852319],[-1433,-1304],[2172,261]],[[326089,851276],[-1036,-2388]],[[325053,848888],[1374,360]],[[326427,849248],[1631,-1734]],[[328058,847514],[-192,-1478],[-1352,-888]],[[326514,845148],[1677,-478]],[[328191,844670],[-351,-790]],[[327840,843880],[1788,-1406],[-105,-1954]],[[329523,840520],[-1919,107]],[[327604,840627],[1770,-2004]],[[329374,838623],[-67,-2163],[1715,-223]],[[331022,836237],[1364,-1026]],[[332386,835211],[413,-1800]],[[332799,833411],[-1180,-2492]],[[331619,830919],[2384,1477]],[[334003,832396],[2116,-949]],[[336119,831447],[602,-1843]],[[336721,829604],[2272,222]],[[338993,829826],[1550,-1809]],[[340543,828017],[-2075,-1304]],[[338468,826713],[-1338,-1782]],[[337130,824931],[-3305,-1274]],[[333825,823657],[-1407,-3367]],[[332418,820290],[2798,2237]],[[335216,822527],[1861,1979],[2011,745]],[[339088,825251],[-733,738]],[[338355,825989],[2156,-387]],[[340511,825602],[780,-2198]],[[341291,823404],[-1090,-1138],[544,-774]],[[340745,821492],[1363,1602]],[[342108,823094],[2030,-901]],[[344138,822193],[867,-2225]],[[345005,819968],[-117,-4172]],[[344888,815796],[403,-2191]],[[345291,813605],[-3558,-4029],[-2204,-189],[-2058,-775]],[[337471,808612],[-1820,-3052]],[[335651,805560],[-2541,-3112]],[[333110,802448],[-3360,-312]],[[329750,802136],[-1208,-580]],[[328542,801556],[-541,763]],[[328001,802319],[-2211,408]],[[325790,802727],[-5979,-155]],[[319811,802572],[-1113,263]],[[318698,802835],[-3408,-641]],[[315290,802194],[-1238,-1292],[-1197,-3822]],[[312855,797080],[-1900,-543],[-2425,-2535]],[[308530,794002],[-2069,-3731]],[[306461,790271],[-2296,918],[2015,-1517]],[[306180,789672],[-362,-1575]],[[305818,788097],[-2223,-4102],[-1561,-2038]],[[302034,781957],[-1700,-646]],[[300334,781311],[-3059,-2827]],[[297275,778484],[-1377,-2793]],[[295898,775691],[-1559,-1400]],[[294339,774291],[178,-929]],[[294517,773362],[-2042,-2022]],[[292475,771340],[3197,2496]],[[295672,773836],[1107,3465]],[[296779,777301],[3496,3685],[1777,736]],[[302052,781722],[2060,1637]],[[304112,783359],[4257,7361]],[[308369,790720],[3962,3441]],[[312331,794161],[3840,2117],[1819,314]],[[317990,796592],[1909,-441]],[[319899,796151],[1596,-1599]],[[321495,794552],[-242,-2954]],[[321253,791598],[-2529,-2381],[-1854,993]],[[316870,790210],[-2160,-986]],[[314710,789224],[2375,-660]],[[317085,788564],[508,-1273]],[[317593,787291],[1847,891],[829,-721]],[[320269,787461],[-581,-2111]],[[319688,785350],[-1130,-1585]],[[318558,783765],[1354,-239],[-206,-1024]],[[319706,782502],[1012,-3836],[1737,-442]],[[322455,778224],[-390,-856],[2121,-1596]],[[324186,775772],[1645,-67],[605,-704]],[[326436,775001],[1465,1460]],[[327901,776461],[1287,-1074]],[[329188,775387],[1280,-2341]],[[330468,773046],[-4118,-2655]],[[326350,770391],[-2201,-1192]],[[324149,769199],[-827,242]],[[323322,769441],[-437,-1166]],[[322885,768275],[-662,939],[-2397,-4604]],[[319826,764610],[-2432,-1819]],[[317394,762791],[-1077,1499]],[[316317,764290],[73,3279]],[[316390,767569],[1610,2165]],[[318000,769734],[-380,163]],[[317620,769897],[3683,3252]],[[321303,773149],[-65,-1013]],[[321238,772136],[2739,1343],[-4291,59]],[[319686,773538],[1662,2730]],[[321348,776268],[-4361,-3630]],[[316987,772638],[-2744,-922]],[[314243,771716],[-701,605]],[[313542,772321],[-185,-2926]],[[313357,769395],[-1799,-588]],[[311558,768807],[-605,-1137]],[[310953,767670],[-1094,730]],[[309859,768400],[-227,-1475],[-1192,1038]],[[308440,767963],[-297,-1992]],[[308143,765971],[-2055,-1928]],[[306088,764043],[-436,491],[-2133,-4651]],[[303519,759883],[-7,-2374]],[[303512,757509],[-863,-2003]],[[302649,755506],[855,-606],[622,-2521]],[[304126,752379],[1425,135]],[[305551,752514],[147,-883]],[[305698,751631],[-1968,-846],[-122,1070]],[[303608,751855],[-1299,-1336]],[[302309,750519],[-615,1812],[-149,-2023]],[[301545,750308],[-2282,-960],[-1832,-39]],[[297431,749309],[-1829,-1560],[-1788,-2452],[-41,-899]],[[293773,744398],[789,-757],[-607,-3565],[-1718,-4294]],[[292237,735782],[-2027,2893]],[[290210,738675],[285,1774],[965,1148]],[[291460,741597],[-1426,-2030]],[[290034,739567],[543,-3247]],[[290577,736320],[990,-3492],[-1557,-5167]],[[290010,727661],[-1114,-2176]],[[288896,725485],[939,4088]],[[289835,729573],[-532,105]],[[289303,729678],[-106,2275],[-896,34]],[[288301,731987],[-146,1414]],[[288155,733401],[731,10]],[[288886,733411],[-652,3495]],[[288234,736906],[1008,1891],[-1523,-1693],[-223,-4105]],[[287496,732999],[130,-2125]],[[287626,730874],[-2186,1904]],[[285440,732778],[-200,-582]],[[285240,732196],[2122,-1790]],[[287362,730406],[793,-1190]],[[288155,729216],[2,-3179]],[[288157,726037],[-965,-204]],[[287192,725833],[910,-1599]],[[288102,724234],[-323,-965]],[[287779,723269],[1111,135],[324,-4366]],[[289214,719038],[-2343,-1292]],[[286871,717746],[2650,-342]],[[289521,717404],[-4,-1498]],[[289517,715906],[-1111,-1735]],[[288406,714171],[-996,914]],[[287410,715085],[-1228,-296],[1282,-1114]],[[287464,713675],[-11,-2922]],[[287453,710753],[-1714,-411]],[[285739,710342],[-1714,-2505]],[[284025,707837],[-491,-2046]],[[283534,705791],[-1328,-131],[-1210,-1146]],[[280996,704514],[-1078,-3193]],[[279918,701321],[-2201,-3349]],[[277717,697972],[-947,-706]],[[276770,697266],[-1416,-2791]],[[275354,694475],[-669,-895]],[[274685,693580],[-487,-3641],[-425,-381]],[[273773,689558],[-173,-2774]],[[273600,686784],[707,-5555]],[[274307,681229],[971,-4407]],[[275278,676822],[1044,-3340]],[[276322,673482],[-873,1609],[249,-2232]],[[275698,672859],[1451,-6955]],[[277149,665904],[514,-3782],[-327,-4090]],[[277336,658032],[-578,-3241]],[[276758,654791],[-1026,-1036]],[[275732,653755],[-1039,-109]],[[274693,653646],[-8,1358]],[[274685,655004],[-699,2748],[-974,901]],[[273012,658653],[-419,2677]],[[272593,661330],[-481,693]],[[272112,662023],[-76,2012]],[[272036,664035],[-620,-123]],[[271416,663912],[-960,3873]],[[270456,667785],[653,1842],[-497,729],[-226,-1423]],[[270386,668933],[-507,756]],[[269879,669689],[508,3791]],[[270387,673480],[25,2380]],[[270412,675860],[-1775,3343],[-1122,2809]],[[267515,682012],[-971,1054]],[[266544,683066],[-941,-1164]],[[265603,681902],[-2600,-1346]],[[263003,680556],[-97,1158]],[[262906,681714],[-1117,1726]],[[261789,683440],[-1941,1375]],[[259848,684815],[-3710,-636]],[[256138,684179],[-614,2384]],[[255524,686563],[-345,-1940]],[[255179,684623],[-2138,287]],[[253041,684910],[-1154,-414],[-744,-1062],[-1769,1264],[-522,-1416]],[[248852,683282],[661,-659]],[[249513,682623],[1216,846]],[[250729,683469],[1064,-2083],[-1018,-1191]],[[250775,680195],[576,-1180]],[[251351,679015],[1383,-1287]],[[252734,677728],[-939,-786],[-1454,2298],[-487,-158]],[[249854,679082],[-446,-1934]],[[249408,677148],[-463,1127]],[[248945,678275],[-1031,-974],[-1498,937],[127,998],[-1802,2243]],[[244741,681479],[-1021,-1654]],[[243720,679825],[-1140,239],[-1402,1077],[-1967,184]],[[239211,681325],[250,991]],[[239461,682316],[-849,-1818]],[[238612,680498],[-1885,-726]],[[236727,679772],[101,1198],[-781,-283]],[[236047,680687],[374,-1965]],[[236421,678722],[-1070,-2410]],[[235351,676312],[-1612,-1917]],[[233739,674395],[-2185,406],[609,-1490]],[[232163,673311],[-1703,-2153]],[[230460,671158],[-707,-2508]],[[229753,668650],[-739,-4166],[424,-3382]],[[229438,661102],[711,-2577]],[[230149,658525],[-218,-2228],[-784,-3382],[-447,-3701],[-527,-10459],[611,-6048],[1434,-5857],[1848,-4415],[463,-3117],[1301,-3490],[1776,-319],[1065,-1103],[700,-2013],[992,121],[1769,1394],[1854,226],[486,847],[2045,617],[474,-1453],[748,-83],[718,995],[-448,1572],[1937,2740],[128,2237],[516,1078],[65,3818],[364,2684],[1481,1571],[2614,827],[2076,1195],[2446,-1000],[340,1034],[845,-1184],[-112,-3179],[-990,-2239],[-684,-2400],[98,-1206],[-710,-1549],[730,-318],[-650,-1369],[438,-382],[-978,-6036],[-564,1513],[68,1863],[-733,-2171]],[[254734,614156],[551,-2078],[-485,-3032],[-115,-5789],[-1061,-2281],[-552,-2116]],[[253072,598860],[807,-749],[26,1103],[1016,-1311]],[[254921,597903],[1695,1071],[1975,-874],[1529,124],[1591,1301],[834,-612],[1417,535],[1150,-1113],[828,122],[1392,-3568],[317,877],[1358,-2223]],[[269007,593543],[-402,-1132],[318,-2737],[-624,-2035],[-431,-4006],[-30,-3870],[-489,-979],[205,-2829],[-412,-968],[492,-1297],[-601,-2026],[628,-2268]],[[267661,569396],[538,-2674],[1861,-4718],[596,-550]],[[270656,561454],[536,-878],[352,-2352],[1288,-440],[1182,-1047],[1434,632],[1976,1912],[1528,2298],[2980,-1135],[1171,-1007],[1968,-3424]],[[285071,556013],[1756,-3888],[-495,3387],[1788,2461],[392,1638],[1378,1095],[-113,1655],[650,5220],[1671,2955],[1084,-715],[149,-1326],[949,3409],[2071,-266],[1644,2467],[1241,1049],[387,1774],[1170,1371],[1256,-502],[347,-1712],[-507,-1093]],[[301889,574992],[-1770,-1729],[996,-4999],[-184,-1673],[-1245,-3722],[978,-2843],[206,-1559],[1080,314],[589,1319],[92,2119],[-927,3305],[-439,3051],[208,1099],[3436,2422],[965,423],[189,1349],[-1043,-281],[-261,1548],[785,1729],[482,-1080],[553,-3055],[1108,229],[1124,-514],[1192,-1604],[719,-3959],[745,-123],[2452,821],[2061,128],[1098,-2218],[2008,-1112],[773,166],[1840,2131],[904,594],[-1209,457],[2226,48],[2206,631],[2286,-52],[-1390,-1150],[-1482,-92],[576,-1175],[-96,-1641],[627,711],[540,-2329],[558,1196],[801,-1492],[673,957],[777,-1549],[1436,-1614],[-724,-1573],[-540,-2932],[-1031,-17],[874,-1108],[1397,1077],[1280,217],[896,-471]],[[333284,555367],[2271,-2812],[1593,-3133],[415,-1304],[-379,-4877],[552,2066],[1201,-387],[2201,-4080],[-13,-3251]],[[341125,537589],[625,2633],[2862,-1170],[309,985],[2763,158],[2165,-1069],[-283,-2660]],[[349566,536466],[858,2508],[1091,-1296],[1542,-820],[2337,-4193],[362,-1666],[395,796],[369,-3017]],[[356520,528778],[293,1479],[909,-1288],[465,-4809],[1038,-6348],[611,-2256],[1394,-1005],[162,-2944],[-1099,-1939],[-1450,-3930],[-1296,-1526],[-337,-1822],[-829,-2189],[-51,-1518],[-832,-2254],[-580,216],[-1208,-1121],[1991,-207],[2923,3845],[-63,-1052],[633,-3830],[797,-1504],[1122,1088],[777,-560],[1126,1153],[-583,-5125],[869,4031],[611,514],[780,2026],[687,-748],[37,2776],[930,2417],[1991,656],[1630,-906],[539,-1131],[1106,-360],[1596,-1875],[516,-50],[360,-2140],[702,1487],[1181,-1655],[316,-1818],[-347,-1900],[631,1216],[-794,-5771],[788,1172],[359,2424],[562,248],[-241,-1873],[722,1339],[1327,483],[208,746],[1232,-527],[1909,-1937],[1037,269],[1549,-1123],[2344,832],[1417,-390],[4135,-5071],[1186,-2956],[1174,-2226],[901,-716],[354,-1181],[1622,-1097],[1696,256],[1195,-446],[873,-2590],[687,-4899],[508,-5301],[-81,-4047],[-898,-5683],[-508,-1778],[-1408,-3208],[-1530,-4216],[-1498,-1993],[-1318,-4010],[-768,-3571],[-1531,-4409],[-720,-666],[-534,1971],[-446,-985],[46,-2116],[-706,-2612],[225,-3039],[-142,-3281],[497,-7164],[-679,-5328],[-251,-3272],[170,-2299],[-924,-1697],[-703,-3848],[111,-3780],[-837,-2749],[-1097,-4903],[-1092,-1994],[-717,-3552],[165,-2457],[-374,-972],[-1620,-1334],[-763,-1606],[-172,-2171],[-2544,-118],[-355,1444],[-384,-1587],[-1783,478],[-2142,-859],[187,-1295],[-1060,-636],[-1424,-2495],[-1411,42],[-2486,-2612],[-750,-1522],[-2054,-2987],[-907,-2483],[-554,856],[-804,-1300],[808,-627],[-400,-1295],[-369,-5255],[344,-2921],[-246,-2144],[61,-3067],[-497,-2961],[-1310,-1753],[-1319,-2915],[-798,-2593],[-739,-3702],[-890,-2796],[-1478,-3452],[-2465,-3759],[-66,1686],[1062,330],[1407,2576],[35,1309],[1311,2457],[120,2768],[-1420,-755],[-850,-4078],[-1413,-1962],[-614,-2972],[183,-1672],[-595,-1612],[-863,-4135],[-1995,-3581]],[[351748,304813],[-1152,-3781],[-1065,-1720],[-2038,-1553],[-2141,931],[-1235,-783],[-2048,1370],[-877,1329],[-1829,-148],[-1586,3347],[102,4325],[781,1710],[-330,2500]],[[338330,312340],[84,-2889],[-705,-902],[-340,-3270],[429,-3137],[-369,-611],[672,-2295],[1444,-1250],[1278,-1741],[402,-1881],[-542,-1270],[247,-2511],[1575,-1673],[72,-2517],[-2010,-5292],[-420,-2021],[-1756,-2074],[-4581,-2384],[-3566,-917],[-1362,-35],[-2145,865],[221,-2313],[670,-773],[-216,-2676],[-432,-414],[-389,-2729],[502,-1888],[-413,-1281],[-1567,-1296],[-2261,-240],[-2999,1993],[-779,-396],[319,-4067],[-113,-2387],[1520,-1779],[-164,-865],[1306,125],[-355,1048],[1203,618],[554,-1733],[-268,-2363],[-1217,-332],[-538,1713],[-906,242],[-1046,-1348],[1966,-1244],[-1851,-1924],[-828,-1993],[125,-2481],[-340,-2539],[-796,-1090],[25,-2053],[-1532,255],[-2087,-1733],[-1709,-4223],[-18,-2223],[2184,-3913],[2162,-521],[724,-1488],[-545,-2854],[345,-678],[-1620,-2377],[-1777,-1691],[-1811,-3667],[-274,-3627],[-1316,-1455],[-1140,2086],[683,-2402],[-1438,-1330],[-726,-3621],[476,-2683],[-919,-671],[1232,-918],[1315,-3804]],[[309879,194532],[-2215,896],[-888,-1280],[-3429,-2057],[-558,-5987],[-839,-617],[-2435,1488],[-365,2243],[1570,125],[1627,2293],[-647,481],[-2973,-2904],[-252,-1222],[-1400,1288],[1047,2929],[3235,851],[-3148,452],[-1327,-3229],[-1452,1405],[1123,769],[-2149,402],[-781,3089],[1281,-689],[417,927],[1519,-309],[1800,2170],[-2483,-1815],[-2259,2307],[741,371],[94,1697],[-2552,1590],[-778,2262],[1136,114],[115,1784],[1262,-2471],[13,1734],[-1234,1726],[822,1300],[119,2195],[1109,-116],[-1319,1322],[-43,3594],[585,2171],[-842,-216],[-306,2754],[2708,30],[-295,2006],[-625,-1623],[-1138,-89],[-845,1433],[1379,3080],[-431,2336],[-463,-578],[-1848,1693],[-1420,-61],[2034,2669],[-395,1687],[2525,640],[334,2069],[590,-172],[-712,-4058],[1074,1612],[-322,-3067],[413,474],[188,3084],[-379,1759],[1468,747],[-674,686],[229,1540],[1732,1446],[209,1764],[-1670,1586],[745,3182],[-290,1045],[620,2411],[258,4425],[985,-785],[-192,2682],[-901,428],[428,1477],[-959,686],[-630,-1405],[-1370,228],[-641,3698],[823,6137],[720,1737],[511,3346],[-850,5081],[188,1934],[-547,2025],[167,3022],[1071,128],[272,2835],[676,1765],[697,4767],[1111,2900],[614,5515],[940,3038],[-218,3303],[419,744],[474,3452],[-668,7212],[-21,4971],[747,1110],[235,2923],[-565,4285],[925,3250],[371,3854],[1128,8282],[-186,3230],[745,3623],[-358,3130],[240,5110],[265,1279],[-543,1170],[69,1845],[643,1234],[678,8031],[-304,4548],[136,5452],[-751,8647]],[[304393,396029],[-2552,3929],[-542,2300],[-1608,1728],[-990,1745],[-907,554],[-2864,2735],[-894,1424],[-2659,2965],[-1193,3037],[-1112,1575],[-1229,4565],[535,2060],[-1801,6912],[-890,1708],[-188,2352],[-1147,2225],[-286,2672],[-1248,4430],[-1602,8720],[-695,2411],[-1014,2220],[-1068,4555],[-968,2471],[-2866,3512],[140,1448],[581,316],[-792,3507],[164,825],[-633,2123],[148,2057],[1346,3503],[1317,2033]],[[276876,484646],[1119,1764],[533,3027],[-769,1335],[-772,-2091],[-1884,3066],[535,667],[-87,4107],[-280,1804],[968,1368],[199,2841],[969,2146],[301,2467],[-176,2219],[964,1156],[2338,1341],[111,1476]],[[280945,513339],[-460,998],[645,1333],[601,-444],[-112,3158],[1381,1074],[1250,2315],[1647,6128],[-973,872],[391,3918],[-320,4114],[-369,716],[792,1440],[-611,2350],[304,1942],[-1503,4294]],[[283608,547547],[-747,1863],[-699,3064],[855,1888],[-2702,3658],[-986,53],[-683,-919],[-175,-1513],[-1718,-1818],[-248,-1254],[1241,-3418],[-1188,-1334],[-1129,-325],[-868,3758],[-170,-1383],[-792,594],[-620,2467],[-1411,1027],[-1234,65],[-555,-1489]],[[269779,552531],[-786,3066],[-854,703],[495,-1782],[-1229,1235],[269,2494],[-717,1428],[-2121,2193],[-156,1498],[-1522,2116],[1046,-2581],[-633,-1417],[-556,1358],[-862,542],[-572,2936],[454,2055],[-669,904],[454,975]],[[261820,570254],[-601,1595],[-1412,2411],[-795,2479],[-2533,4425],[917,448]],[[257396,581612],[-422,2214],[-903,274]],[[256071,584100],[-324,-1295],[-2600,608],[-1141,1154],[-1462,486],[-809,1045]],[[249735,586098],[-1421,1141],[-1498,-20],[-1869,1793],[-1156,1879]],[[243791,590891],[-1594,3514],[-3075,5421],[-2071,878],[-664,1278],[-1566,-2624],[-2081,-1668],[-826,-244],[-1873,1525],[-1582,341],[-1067,1419],[-1060,583],[-672,1363],[-2579,1095],[-927,1190],[-2287,1659],[-2090,2672],[-686,1604],[-2368,833],[-2062,1555],[-1307,2980],[-2850,2850],[-1510,3949],[-520,2427],[1136,1146],[-645,1170],[710,2030],[78,2201],[-618,755],[-605,2191],[9,2008],[-405,1781],[-1696,3365],[-1896,4861],[-1229,2038],[43,765],[-1221,745],[-712,2132],[478,359],[-1987,2304],[-772,333],[395,1499],[-861,-835],[-523,797],[-113,1810],[627,1615],[-786,2400],[-756,-44],[-525,2230],[-1483,1442],[-383,1962],[238,1246],[-1643,609],[-973,2471],[-579,513],[-1338,3248],[-171,1485],[-1430,4241],[-1034,4787],[177,2286],[-1603,987],[-900,1680],[-560,-723],[-2178,2330],[399,-1502],[-257,-2907],[691,-3848],[-45,-1593],[770,-2416],[1714,-2742],[710,-2611],[818,-758],[310,-1700],[620,-519],[380,-3544],[1124,-1793],[904,-2632],[392,-2373],[187,1192],[627,-1020],[659,-3449],[112,-1989],[716,-1557],[991,-4374],[51,-2649],[811,-1428],[290,1446],[667,-1007],[1672,-4114],[-103,-1572],[-1293,-1949],[-452,709],[-769,3551],[-2934,4290],[-1815,3028],[47,3840],[-893,4299],[-1788,2188],[-375,2151],[-1230,-1333],[-673,1453],[-1679,1491],[-263,1261],[-1379,2434],[1296,-343],[748,527],[700,3278],[-269,1062],[-2357,4615],[-1888,2203],[-394,3242],[-501,657],[-184,2309],[-1667,4507],[115,1695],[-631,867],[-778,3175]],[[174644,697459],[-943,4516],[-1703,2527],[-916,129]],[[171082,704631],[-267,1620],[-1770,561]],[[169045,706812],[-1284,1813]],[[167761,708625],[-2431,318]],[[165330,708943],[-454,642]],[[164876,709585],[31,2941]],[[164907,712526],[-630,1712]],[[164277,714238],[-2825,5721],[-10,3601]],[[161442,723560],[-1429,1591]],[[160013,725151],[-330,3344],[1232,-1740]],[[160915,726755],[-875,2858],[1857,435]],[[161897,730048],[-2130,443],[-104,-1673]],[[159663,728818],[-1141,1357],[-525,2333]],[[157997,732508],[-1611,2713],[-511,5649],[-1220,2318]],[[154655,743188],[-132,1417]],[[154523,744605],[663,2836]],[[155186,747441],[179,2455]],[[155365,749896],[-796,4376]],[[154569,754272],[-513,4088],[1086,5207]],[[155142,763567],[249,6434]],[[155391,770001],[361,4735]],[[155752,774736],[49,3585]],[[155801,778321],[1918,-169]],[[157719,778152],[-677,696]],[[157042,778848],[-1689,49]],[[155353,778897],[-108,4478]],[[155245,783375],[-734,3693]],[[154511,787068],[-681,1455]],[[153830,788523],[-247,2821]],[[153583,791344],[2040,-1255]],[[155623,790089],[2642,-516]],[[158265,789573],[683,333]],[[158948,789906],[557,-5003]],[[159505,784903],[623,465],[-108,2659],[419,1127],[-1186,2693]],[[159253,791847],[344,1760]],[[159597,793607],[-677,1367]],[[158920,794974],[-724,0]],[[158196,794974],[-795,2762]],[[157401,797736],[-1545,209]],[[155856,797945],[-35,2883]],[[155821,800828],[-659,-1117]],[[155162,799711],[-753,-87],[-1027,1435]],[[153382,801059],[-768,2925]],[[152614,803984],[-3864,212],[1534,798]],[[150284,804994],[-1721,237]],[[148563,805231],[-319,1130]],[[148244,806361],[-1182,-282]],[[147062,806079],[-1807,1681]],[[145255,807760],[176,1939]],[[145431,809699],[-623,1758]],[[144808,811457],[214,3046]],[[145022,814503],[-863,-2968]],[[144159,811535],[-708,2195]],[[143451,813730],[699,4431]],[[144150,818161],[-721,-480]],[[143429,817681],[-797,2477],[-1191,731]],[[141441,820889],[-93,1622],[1590,-1307]],[[142938,821204],[-1159,2494]],[[141779,823698],[-903,-2657]],[[140876,821041],[-776,-838],[-2144,2799]],[[137956,823002],[811,2426],[-1073,1704],[2414,6170]],[[140108,833302],[-1178,-614]],[[138930,832688],[-111,3136]],[[138819,835824],[-33,-3497]],[[138786,832327],[-492,-1612]],[[138294,830715],[-1003,-1519]],[[137291,829196],[-763,226],[-550,2074]],[[135978,831496],[591,1033]],[[136569,832529],[-848,3942]],[[135721,836471],[-1788,-716]],[[133933,835755],[-554,-2023]],[[133379,833732],[-667,1102],[1054,2601]],[[133766,837435],[-2970,5257]],[[130796,842692],[-1535,739]],[[129261,843431],[-245,3098]],[[129016,846529],[-1818,3185],[-1265,901]],[[125933,850615],[-1300,2714],[-643,3415],[-387,-1286],[1259,-5305]],[[124862,850153],[-2288,517],[-760,2339]],[[121814,853009],[-2340,1455]],[[119474,854464],[2577,-3446],[-1447,-1231]],[[120604,849787],[-2671,1992]],[[117933,851779],[-2246,2998]],[[115687,854777],[-4019,2719]],[[111668,857496],[796,900],[-18,1888],[-1937,-1719]],[[110509,858565],[-1741,131]],[[108768,858696],[-2296,1310]],[[106472,860006],[-3544,753]],[[102928,860759],[-3338,-478],[-2094,1888]],[[97496,862169],[584,1979]],[[98080,864148],[-1548,-1712],[-1808,581]],[[94724,863017],[-2054,2483]],[[92670,865500],[155,1366]],[[92825,866866],[-2243,-1243],[-1708,299],[704,1484]],[[89578,867406],[-2237,-2466],[1647,-1883]],[[88988,863057],[-1297,-2937]],[[87691,860120],[-2679,691]],[[85012,860811],[-563,-1987]],[[84449,858824],[-2732,-1219]],[[81717,857605],[-980,-1869]],[[80737,855736],[-2232,-359],[-311,1290],[1251,652],[-1261,1574]],[[78184,858893],[1116,2491]],[[79300,861384],[265,3083],[2543,1781],[2357,-176]],[[84465,866072],[-980,1780],[-1852,41],[-3117,-2313]],[[78516,865580],[-46,-923],[-2510,-3061]],[[75960,861596],[-292,-1880]],[[75668,859716],[-1255,-464]],[[74413,859252],[-2436,-2840]],[[71977,856412],[-250,-1231]],[[71727,855181],[2343,-1763]],[[74070,853418],[-1904,-2162]],[[72166,851256],[-630,-1976]],[[71536,849280],[-2112,-849],[-2141,-2654]],[[67283,845777],[-1945,-1424]],[[65338,844353],[-420,-1884]],[[64918,842469],[-3444,-2160],[-1857,-1836]],[[59617,838473],[-700,-2064]],[[58917,836409],[-2649,-848]],[[56268,835561],[-2100,-1816],[-2726,-826]],[[51442,832919],[60,1370]],[[51502,834289],[-1708,-2902]],[[49794,831387],[-1299,613],[-369,-1458],[-1835,-933]],[[46291,829609],[156,1675]],[[46447,831284],[1035,437]],[[47482,831721],[2081,3103]],[[49563,834824],[2616,1788]],[[52179,836612],[2565,-1280]],[[54744,835332],[-686,1192],[658,1823]],[[54716,838347],[3644,3235]],[[58360,841582],[3480,4076]],[[61840,845658],[594,5174]],[[62434,850832],[1060,2703]],[[63494,853535],[-2444,-1407]],[[61050,852128],[-2094,1554]],[[58956,853682],[-36,-2735]],[[58920,850947],[-817,171],[-1633,2615]],[[56470,853733],[-694,-540]],[[55776,853193],[-1229,1370]],[[54547,854563],[-2774,-2261]],[[51773,852302],[-2177,-150]],[[49596,852152],[1169,889],[-831,2901]],[[49934,855942],[541,1805],[-1646,4120]],[[48829,861867],[788,2379],[-1521,-2468],[319,-1655],[-3712,-1083],[-2099,2944],[-1920,1407],[1524,2078]],[[42208,865469],[2985,-1789]],[[45193,863680],[860,991]],[[46053,864671],[-4875,1234]],[[41178,865905],[833,718]],[[42011,866623],[-2265,1262],[-1324,2079]],[[38422,869964],[1541,1295]],[[39963,871259],[-262,1369]],[[39701,872628],[1425,2211],[1153,45]],[[42279,874884],[-183,1894]],[[42096,876778],[2048,2730],[2081,-1279]],[[46225,878229],[2049,1303],[939,1561]],[[49213,881093],[3288,170],[891,1546]],[[53392,882809],[-581,2562]],[[52811,885371],[-1186,1629]],[[51625,887000],[1341,313]],[[52966,887313],[-707,2043],[-1591,-638]],[[50668,888718],[-2910,-2619]],[[47758,886099],[-2518,1268]],[[45240,887367],[-3294,-756]],[[41946,886611],[-3455,724]],[[38491,887335],[-757,2036]],[[37734,889371],[-1425,1365],[2031,880]],[[38340,891616],[-3351,690],[-1901,1397]],[[33088,893703],[2816,1300]],[[35904,895003],[4514,3156]],[[40418,898159],[4783,1224]],[[45201,899383],[-850,-2375]],[[44351,897008],[940,-780],[5219,-179]],[[50510,896049],[1003,1348],[-1036,531]],[[50477,897928],[-2165,3101]],[[48312,901029],[1639,-653]],[[49951,900376],[300,-1330]],[[50251,899046],[3496,-1106]],[[53747,897940],[1079,1182]],[[54826,899122],[-1672,583],[-1483,-705]],[[51671,899000],[-1273,880]],[[50398,899880],[651,1653]],[[51049,901533],[-3832,284]],[[47217,901817],[-1997,997]],[[45220,902814],[-1123,2436],[-3503,2600]],[[40594,907850],[-3890,1860],[1128,389],[476,2725]],[[38308,912824],[5296,304],[3170,2675],[580,2193],[2441,2391],[2993,846]],[[52788,921233],[4671,3400],[3655,-196],[4246,3331],[6320,-3593]],[[71680,924175],[2673,779],[2778,-724]],[[77131,924230],[-661,-929]],[[76470,923301],[3461,-1391]],[[79931,921910],[5432,486],[4343,-1680],[5230,-340],[1738,-896]],[[96674,919480],[5497,638],[5029,-2744]],[[107200,917374],[1127,-14]],[[108327,917360],[5056,-801],[2927,-2154]],[[116310,914405],[7684,-2700]],[[123994,911705],[-1429,1308],[514,2335]],[[123079,915348],[4976,1286],[-762,-1632]],[[127293,915002],[2808,1073]],[[130101,916075],[898,1283]],[[130999,917358],[4733,1519]],[[135732,918877],[1772,1400]],[[137504,920277],[2361,-862]],[[139865,919415],[-3643,-2166],[-2508,-490]],[[133714,916759],[-4211,-3927]],[[129503,912832],[1868,-424],[-35,1565]],[[131336,913973],[2585,2091],[2153,-21]],[[136074,916043],[118,-1301],[1714,2648],[3456,1339]],[[141362,918729],[384,-1004]],[[141746,917725],[3576,3246],[-853,1857]],[[144469,922828],[2368,-1982]],[[146837,920846],[1462,-3015],[3403,-2258]],[[151702,915573],[516,2842]],[[152218,918415],[2103,1669],[888,-2492]],[[155209,917592],[-837,-1840]],[[154372,915752],[2268,-13]],[[156640,915739],[1622,2564]],[[158262,918303],[4151,-203]],[[162413,918100],[3441,-2104],[3955,-969]],[[169809,915027],[2149,-1268],[5654,-1220],[1190,803]],[[178802,913342],[3382,-1855],[1248,-1543]],[[183432,909944],[-2225,-763],[-1858,-2181]],[[179349,907000],[4868,-1198]],[[184217,905802],[3463,-90]],[[187680,905712],[4527,874],[1954,948]],[[194161,907534],[1310,-1538]],[[195471,905996],[2882,-840]],[[198353,905156],[1843,-2750]],[[200196,902406],[-1574,-204]],[[198622,902202],[2182,-2087],[233,1559]],[[201037,901674],[1306,-719]],[[202343,900955],[-2266,5037]],[[200077,905992],[587,1778],[3713,998]],[[204377,908768],[1785,1931]],[[206162,910699],[-2297,-1002]],[[203865,909697],[-2809,-156]],[[201056,909541],[-318,-933]],[[200738,908608],[-2645,614]],[[198093,909222],[1036,1975]],[[199129,911197],[5970,1833],[1735,-1193]],[[206834,911837],[309,-1542]],[[207143,910295],[3430,-2530]],[[210573,907765],[1999,496]],[[212572,908261],[2172,-1797],[3159,-703]],[[217903,905761],[3052,868]],[[220955,906629],[3637,-686],[2041,494]],[[226633,906437],[2659,-1127]],[[229292,905310],[690,1410]],[[229982,906720],[-2511,285],[-1500,2729]],[[225971,909734],[3444,788],[1904,-2579]],[[231319,907943],[2097,1113]],[[233416,909056],[-1115,-4119]],[[232301,904937],[639,-1671]],[[232940,903266],[1171,265]],[[234111,903531],[1016,-1990]],[[235127,901541],[265,1670]],[[235392,903211],[-1089,2813]],[[234303,906024],[528,1682]],[[234831,907706],[1666,121],[3381,3503]],[[239878,911330],[-2552,1651]],[[237326,912981],[1336,1328]],[[238662,914309],[-527,1892],[-4941,2227]],[[233194,918428],[-1376,2940],[1852,1313]],[[233670,922681],[-1862,1539],[398,2754]],[[232206,926974],[2336,374],[-854,1401]],[[233688,928749],[1864,1958]],[[235552,930707],[1789,446]],[[237341,931153],[4468,-4247]],[[300051,207169],[-2273,162],[-1078,1268],[-288,-1355],[935,-2423],[-49,1561],[2753,787]],[[299149,209912],[125,889],[-1858,1013],[-276,-1382],[2009,-520]],[[302316,229071],[-1891,1251],[-1754,-1661],[-682,497],[-140,-2140],[1434,1704],[1323,512],[1710,-163]],[[309232,235614],[-815,831],[338,-2143],[477,1312]],[[297313,260123],[1023,603],[-694,1240],[-329,-1843]],[[352392,311289],[-108,-176]],[[352284,311113],[-1241,-2707],[224,-2392]],[[351267,306014],[47,-79]],[[351314,305935],[328,2335],[1093,2133],[674,-457],[498,2232],[-353,1780],[-1162,-2669]],[[327139,322385],[-161,1110],[-989,404],[-932,-1013],[701,-1336],[1381,835]],[[349258,362140],[-575,-4635],[-661,-1153],[536,-871],[-254,-1439]],[[348304,354042],[1138,1142],[-491,355],[329,3186],[-22,3415]],[[357177,383706],[1398,630],[-63,1361],[-1365,-992],[30,-999]],[[375077,393444],[-787,942],[-393,-2275],[1180,1333]],[[307956,408551],[363,-118]],[[308319,408433],[-123,-1621]],[[308196,406812],[119,-148]],[[308315,406664],[1162,1474],[-670,661],[92,1293],[-1567,2729]],[[307332,412821],[-125,120]],[[307207,412941],[-782,1344],[-698,-797],[-202,-2544],[1566,-1193],[-100,-903],[965,-297]],[[386332,449873],[-955,-620],[1,-1374],[-2042,457],[-987,-1134],[-18,-1639],[-573,-510],[-130,-2422],[-562,101],[197,-1444],[-1153,-2421],[1101,-162],[107,1778],[988,2462],[591,-1433],[-573,2822],[1189,2400],[2077,501],[251,1197],[713,268],[-222,1173]],[[361918,478518],[-11,-1010],[1199,-4197],[-183,2487],[-773,6122],[-664,-2804],[432,-598]],[[347518,533051],[241,742],[-889,541],[-578,-2791],[1280,431],[-54,1077]],[[264404,571429],[-1236,3095],[-1495,1921],[-282,-2397],[1061,-2885],[1724,-1068],[228,1334]],[[260823,577661],[-529,957],[-819,-406],[762,-1350],[586,799]],[[253117,597801],[-1417,-1326],[859,-263],[558,1589]],[[214693,624354],[-509,739],[-1329,-346],[1629,-807],[209,414]],[[229370,654923],[-756,-1152],[-164,-7372],[197,3327],[723,5197]],[[223732,665013],[790,-2918]],[[224522,662095],[147,1097],[-937,1821]],[[276031,664008],[-361,1749],[-824,-1134],[869,-1739],[316,1124]],[[178856,701850],[-936,1451],[-314,-514],[758,-1695],[492,758]],[[256497,710969],[1819,-777],[-1046,1001],[-773,-224]],[[184345,716995],[-1002,2446],[-638,-1149],[-571,1110],[-1069,-685],[1689,-686],[611,942],[980,-1978]],[[255397,721963],[-173,-1865],[581,-993],[-408,2858]],[[193423,729241],[-1653,-4426],[1470,3320],[183,1106]],[[311702,775814],[-211,-115]],[[311491,775699],[1149,-785]],[[312640,774914],[-32,205]],[[312608,775119],[-906,695]],[[276535,778914],[1280,-720],[1530,442],[-605,728],[-2205,-450]],[[287558,783985],[198,1824],[-1143,423],[945,-2247]],[[285352,786669],[38,-1448],[902,834],[-940,614]],[[305332,797621],[505,623],[-1163,1887],[-1107,-2007],[556,-676],[646,1365],[563,-1192]],[[255471,800710],[-1691,1871],[-706,-1305],[-186,-3291],[694,508],[1473,-716],[416,2933]],[[204952,786635],[-300,2367],[-1680,-1540],[-2177,-222],[2364,-334],[1329,1284],[464,-1555]],[[216258,786530],[2076,-365],[547,822],[-2660,-124],[-373,1705],[-374,-2070],[784,32]],[[292969,791821],[1197,-526],[-1333,1584],[62,966],[-2380,-2160],[2023,-479],[431,615]],[[299848,791779],[775,925],[-1541,348],[766,-1273]],[[241895,791656],[1342,-252],[-997,1806],[-1634,-469],[1900,-587],[-611,-498]],[[277277,793902],[869,-1058],[1584,-150],[-1498,1579],[-955,-371]],[[236909,794206],[1774,1729],[-1590,1716],[1090,153],[-791,2728],[-137,-1229],[-1568,-600],[125,-942],[1165,961],[-1591,-2895],[-35,-1486],[1558,-135]],[[289481,768262],[-51,62]],[[289430,768324],[-700,-759]],[[288730,767565],[-2596,-1733],[313,-777]],[[286447,765055],[-914,-398]],[[285533,764657],[-1043,963]],[[284490,765620],[-4103,-1205]],[[280387,764415],[-1412,-1521],[-570,-1532]],[[278405,761362],[1213,-722]],[[279618,760640],[345,264]],[[279963,760904],[445,254]],[[280408,761158],[2439,649]],[[282847,761807],[1794,-755],[1535,60]],[[286176,761112],[2069,1618],[-157,1767]],[[288088,764497],[604,887]],[[288692,765384],[-782,637]],[[287910,766021],[1571,2241]],[[296185,766268],[405,1760],[224,3722],[-656,-811],[27,-4671]],[[279310,767227],[793,829],[-583,1473],[-210,-2302]],[[221420,771418],[-171,4958],[-770,-1223],[788,-1524],[153,-2211]],[[188961,747632],[-2295,3939],[-448,-1716],[1643,-4008],[1100,1785]],[[269232,755176],[1352,232]],[[270584,755408],[-154,1442]],[[270430,756850],[-395,631]],[[270035,757481],[-1858,-5463],[2711,-2040]],[[270888,749978],[2103,669]],[[272991,750647],[1374,1537]],[[274365,752184],[2483,1602],[3126,3047],[921,1445]],[[280895,758278],[-523,1818]],[[280372,760096],[25,-1444]],[[280397,758652],[-3232,-520]],[[277165,758132],[-641,-953],[-2648,97],[-1340,-2209],[-1992,-1373],[-1399,271],[87,1211]],[[264522,776026],[3593,-2701]],[[268115,773325],[515,-1768],[-3,-2116]],[[268627,769441],[-101,-1826]],[[268526,767615],[-1523,-2444]],[[267003,765171],[399,-1990]],[[267402,763181],[979,1761]],[[268381,764942],[1210,848]],[[269591,765790],[788,-1261]],[[270379,764529],[684,-4957]],[[271063,759572],[1758,1833],[212,4762]],[[273033,766167],[1140,2761],[54,1145],[-1164,2733],[1113,-15]],[[274176,772791],[103,-1306]],[[274279,771485],[1011,-1465]],[[275290,770020],[1860,-1609]],[[277150,768411],[327,1762]],[[277477,770173],[1086,-270],[-1024,2062]],[[277539,771965],[124,1381]],[[277663,773346],[-857,337]],[[276806,773683],[-1300,3269],[-2164,92]],[[273342,777044],[-52,905]],[[273290,777949],[-3917,367],[-2962,1064]],[[266411,779380],[-204,1195]],[[266207,780575],[-931,-470]],[[265276,780105],[321,2503]],[[265597,782608],[-1074,776]],[[264523,783384],[492,1679],[-1166,1888],[443,1843]],[[264292,788794],[-2565,120],[-905,1422],[-812,3086]],[[260010,793422],[-2298,265]],[[257712,793687],[-2889,1172]],[[254823,794859],[85,-2231]],[[254908,792628],[-749,1398]],[[254159,794026],[-755,-1481]],[[253404,792545],[-1184,-783]],[[252220,791762],[-963,-2595]],[[251257,789167],[-159,-126]],[[251098,789041],[-3757,-2772]],[[247341,786269],[-3076,-4176],[1571,-312]],[[245836,781781],[1536,1111]],[[247372,782892],[581,-1569]],[[247953,781323],[744,-620],[3094,1764],[1957,2074]],[[253748,784541],[560,-2551]],[[254308,781990],[647,889]],[[254955,782879],[1606,-740],[777,-1790],[2002,-423],[1404,1385]],[[260744,781311],[3234,497]],[[263978,781808],[-185,-1570]],[[263793,780238],[1964,-86]],[[265757,780152],[279,-1823]],[[266036,778329],[689,-1315],[-1813,540],[-473,-1056]],[[264439,776498],[-1622,1388],[-3271,-1340]],[[259546,776546],[-1141,-944]],[[258405,775602],[-23,1149]],[[258382,776751],[-2355,-5774],[-386,-2314]],[[255641,768663],[1013,1747]],[[256654,770410],[742,-421]],[[257396,769989],[-1568,-9070],[335,-2911],[-45,-3211]],[[256118,754797],[1069,-3288],[943,77],[1224,1433]],[[259354,753019],[925,2999]],[[260279,756018],[215,2861]],[[260494,758879],[-889,4365]],[[259605,763244],[64,2586]],[[259669,765830],[620,1522]],[[260289,767352],[163,2308],[1724,2799],[187,-2494],[482,2993]],[[262845,772958],[1135,685]],[[263980,773643],[-40,2219]],[[263940,775862],[582,164]],[[304140,818688],[-1150,-344],[1394,-1772],[-244,2116]],[[65144,846949],[-1377,-1292],[582,-619],[795,1911]],[[66775,847565],[-2377,1168],[-43,-804],[2420,-364]],[[204838,846536],[-2252,-1307],[-60,-1422],[2764,1356],[-452,1373]],[[159718,836983],[-4078,-138],[-2403,3863],[2182,-4682],[2444,-3138],[-1641,2784],[123,902],[1602,-244],[1771,653]],[[294473,836638],[-896,1402],[-1408,66],[1583,-1987],[721,519]],[[287370,837457],[1688,160],[-1186,2129],[-502,-2289]],[[226959,845332],[-1977,-3354],[-895,360],[-623,-1640],[1940,770],[1679,1606],[709,1631],[-833,627]],[[217431,847801],[-739,1057],[-2498,-3777],[890,-3675],[-1994,-3118],[2283,1800],[1750,3923],[-717,578],[1529,2656],[-504,556]],[[224031,819892],[-1513,331],[-906,2234],[-20,2267],[993,1014],[-1359,95],[100,-3610],[-1444,-985],[2419,-1946],[1424,-267],[306,867]],[[288610,823079],[-1655,-222],[1641,2468],[-731,426],[-1104,-1648],[-2553,-1367],[1384,-1103],[3449,1143],[-435,-1247],[3825,2261],[-3126,-11],[-695,-700]],[[238232,823064],[293,934],[-2229,-387],[1936,-547]],[[307358,825126],[-1409,1693],[-785,-1418],[2194,-275]],[[249008,823252],[2079,32],[-1092,680],[-987,-712]],[[324512,825405],[-1589,-197],[-1719,3075],[-270,-2147],[-2602,-1459],[-1492,1564],[-1366,3216],[-533,-1128],[360,-2072],[598,1214],[2607,-3549],[-532,-2308],[1414,-406],[-675,2221],[1942,1945],[2048,-1578],[1809,1609]],[[239103,829583],[-1539,-201],[-796,-2306],[3100,2177],[-765,330]],[[210011,831510],[-625,870],[-1711,-523],[0,-1552],[2336,1205]],[[296716,831144],[381,-900],[2277,1985],[-2658,-1085]],[[200587,831811],[341,3160],[-1051,-1398],[710,-1762]],[[198441,839505],[801,-1889],[-1089,-3686],[1506,2928],[134,1892],[-1352,755]],[[72301,858817],[-990,131],[-4216,-1503],[284,-1203],[3138,692],[1784,1883]],[[189398,851653],[-1446,1288],[-156,-1182],[1679,-1461],[-77,1355]],[[214341,850290],[-320,1200],[-1828,-734],[-127,-2899],[1236,-540],[-55,1497],[1094,1476]],[[196575,855010],[3878,528],[1660,785],[-6410,1314],[-4764,-5137],[519,-1174],[1843,746],[1460,2225],[1814,713]],[[45922,862634],[-1068,118],[227,-1580],[841,1462]],[[237151,884822],[-4381,983],[1163,-1590],[3218,607]],[[200152,884438],[1232,-1804],[1100,618],[-602,1404],[-1730,-218]],[[197160,876811],[-4173,820],[-2413,-1321],[-2524,-3480],[-3534,-686],[-2077,2354],[-1088,-236],[-1597,1370],[-2081,700],[2129,-1535],[-52,-1359],[1818,-2182],[-2561,-761],[-1090,-3303],[-2260,868],[-1431,-1376],[5015,-1535],[4558,940],[432,1804],[1965,529],[2115,1386],[1725,2991],[2287,2467],[2858,615],[-4271,-195],[1696,1256],[3781,-660],[773,529]],[[227864,875283],[1455,-753],[-604,2622],[-2211,-210],[1360,-1659]],[[174114,879194],[-2171,2142],[-1464,-1382],[2469,-1326],[1166,566]],[[223316,877713],[1289,-177],[-572,1443],[-717,-1266]],[[216848,879267],[-278,-1756],[2068,-1532],[1129,1719],[-638,1275],[1133,2222],[-3414,-1928]],[[75167,922797],[-1865,997],[-1794,-452],[1671,-1071],[1988,526]],[[235418,889826],[-1894,-1],[1059,-1426],[835,1427]],[[170371,889803],[926,-1297],[190,2480],[-1558,109],[442,-1292]],[[183061,892047],[4868,-1480],[-3543,1706],[-1325,-226]],[[172660,898241],[-5222,-665],[-2009,496],[1003,1534],[2743,1510],[-2168,714],[-5842,-2330],[-8562,-2301],[683,-1816],[882,1255],[3065,-105],[1057,709],[4826,-967],[-4470,-3583],[97,-1239],[-1648,-959],[3904,-918],[1368,2385],[2457,1431],[313,-1871],[-2558,-2428],[670,-314],[4694,3443],[-1244,1057],[1024,1162],[4406,-528],[-460,1256],[1442,2062],[-451,1010]],[[205669,860170],[-968,611],[-187,-1600],[1155,989]],[[215141,862093],[1461,-2328],[380,1831],[-1041,2430],[-800,-1933]],[[180383,862136],[-2062,-446],[1417,-860],[645,1306]],[[211567,863464],[469,1666],[-2297,-644],[1828,-1022]],[[246263,806680],[1135,-86],[1416,976],[-493,565],[-2058,-1455]],[[227891,803055],[-791,2144],[-1435,1635],[524,1471],[-1651,3160],[-1053,-372],[1063,-1009],[428,-2393],[1539,-5501],[1376,865]],[[297500,807728],[-464,1449],[-2292,-2425],[-666,-3424],[3422,4400]],[[310402,809224],[-777,2114],[-1676,-587],[-28,-2263],[1127,-2089],[1084,1348],[270,1477]],[[223127,810497],[-459,3731],[448,3890],[-2325,1609],[-1629,-536],[773,-1941],[439,1346],[1102,-643],[766,-1808],[-463,-2670],[306,-2135],[1042,-843]],[[232297,805260],[230,3092],[-1539,3645],[-982,4778],[-2150,7742],[-137,2549],[-772,-2633],[1109,-1345],[-2860,764],[-863,-3896],[1196,-2873],[1726,-2635],[1012,-2317],[909,1444],[685,-1585],[-8,-2498],[522,1313],[897,-656],[-665,-1799],[140,-4678],[985,40],[565,1548]],[[549943,856209],[1470,468],[-621,-1769],[-4158,-3105],[-647,-4580],[-77,-4409],[-1475,-5009],[-3563,-524],[-1386,-1786],[-115,-2583],[-3578,87],[257,1673],[-1309,3550],[1046,1924],[-1283,1709],[-1907,4808],[-1288,4490],[-210,3570],[535,-247]],[[531634,854476],[-1539,872],[-736,2390],[-1083,-3423],[-1559,-375],[-4034,-4745],[-1945,-736],[-2530,608],[-2356,2371],[-263,2898],[1352,-846],[-409,2542],[1256,1551],[-2893,-2338],[-641,356],[481,2466],[2520,1121],[-1338,185],[2188,3226],[-1013,-363],[-1830,-3085],[-1066,-935],[369,5343],[-653,2776],[2710,469],[2933,-155],[-1065,684],[-3701,-584],[-893,1928],[933,308],[-1141,1334],[598,2662],[3360,2673],[3717,-152],[985,1100],[-3515,-417],[635,1522],[3033,790],[1331,1316],[-504,1315],[3607,530],[784,-1359],[2170,391],[-97,970],[1793,1065],[-178,1447],[-1033,-1651],[-2808,-1472],[-859,1617],[2641,3694],[2662,1931],[-96,1372],[1862,1204],[315,2306],[2350,3925],[257,2429],[2493,2835],[3683,755],[-2654,55],[1340,1886],[1399,-731],[-449,1862],[-1451,-535],[742,1664],[2665,1616],[702,-2026],[1087,4379],[2052,1029],[5018,5619],[6210,1572],[-214,1305],[3691,837],[1593,-2260],[337,1506],[2891,2692],[957,1816],[2788,-920],[-1374,-1782],[-638,-2626],[4064,4762],[217,-2979],[2696,2473],[115,1562],[2209,-687],[-629,-4073],[1232,2796],[1369,599],[5116,-3473],[-2828,-1055],[-3180,289],[2278,-998],[96,-1165],[3428,20]],[[585749,918146],[1619,-556],[1477,1564],[2659,-1195],[-2125,-461],[559,-1156],[3629,-1000],[6038,-702],[5202,-2960],[1943,-1993],[7045,-3805],[1090,-2984],[-472,-2272],[-1854,-2249],[-3423,-1864],[-2477,-401],[-8011,1964],[-1914,1276],[-4651,1378],[-610,1440],[-2875,442],[3672,-3732],[398,-1198],[2089,-617],[1871,-2137],[-1055,-2777],[1103,-2428],[183,-2524],[2160,-1076],[1994,-2224],[2992,-1123],[1747,1259],[-326,1744],[-2139,523],[-1820,2600],[985,1926],[1792,-380],[1337,-1361],[4857,-1786],[1908,1194],[-1796,3384],[51,1470],[2431,2165],[2178,948],[2041,2348],[2841,-617],[2420,-2411],[1067,3929],[-547,2535],[-1415,917],[1231,4391],[-157,1964],[-2125,1667],[4650,-181],[2261,-582],[2218,-3738],[-3227,-540],[-1637,-2410],[1730,-980],[1177,-1969],[1407,-313],[3232,1041],[608,3603],[2786,872],[5447,3665],[4181,1530],[4050,2297],[358,-3321],[-1862,-994],[4447,-389],[1546,2168],[1738,480],[3009,-562],[2906,1989],[2456,689],[870,-1586],[-754,-1742],[2218,-132],[-4,1685],[1648,134],[1234,1527],[-2119,3579],[2348,1544],[6515,-1044],[7565,-3785]],[[683568,913720],[921,-525]],[[684489,913195],[1628,-440],[3802,-3313],[2137,3770],[-1660,97],[-1492,3039],[-3338,1062],[1335,6394],[-1284,348],[264,2874],[3755,2373],[2138,5847],[1684,1349],[5153,96],[3644,-1317],[-521,-3626],[-1979,-3149],[1859,-2350],[330,-4111],[-641,-1080],[161,-7077],[791,-1571],[2044,-1426],[-1135,-2330],[36,-1874],[-4447,-6544],[-1700,-1258],[-2951,1762],[-2399,-339],[503,-1242],[3181,-1401],[4382,-565],[1390,1860],[3818,2575],[785,2481],[1930,2087],[-1050,3876],[523,1958],[5221,1346],[2165,-3014],[-178,-4094],[1390,-1121],[3465,-1],[-3706,964],[213,2598],[917,408],[-956,3814],[-4583,1967],[-3295,-856],[-2326,143],[-1159,3510],[2176,5162],[-3492,5133],[1626,2370],[3668,1777],[-571,3952],[1619,-92],[1033,-2964],[-1372,-2860],[235,-2794],[2162,-730],[4109,-301],[2772,-1030],[-1042,1614],[-2032,268],[-3247,1682],[-777,1866],[2331,726],[1887,-1131],[1894,653],[-2114,1421],[2809,1202],[2609,-86],[3724,-1726],[2079,-2032],[4097,15],[-190,-1948],[-1652,-948],[-179,-4244],[1696,2437],[448,-2219],[-968,-2149],[2928,1948],[-1624,3301],[1167,2908],[-3855,3810],[-3768,1485],[-882,3542],[205,2858],[8226,581],[8462,1349],[1218,-415],[-3340,-1963],[3592,723],[248,1562],[-2852,3226],[2908,-352],[-3997,1668],[2391,220],[2833,2650],[6982,2734],[9347,1558],[-1606,1308],[4455,456],[4166,-414],[1172,-1130],[6013,2082],[3232,-632],[-2833,2042],[5662,264],[405,2757],[5948,3767],[2455,616],[5222,-1431],[-1417,-1488],[-3284,-805],[7609,-399],[1356,-639],[-2197,-2093],[2737,-375],[1121,1235],[8576,27],[5992,-2794],[1261,-4744],[-2226,-2581],[-8568,-4106],[-4558,-3720],[-2580,-434],[-3005,-1853],[-1335,-2793],[2138,1794],[3535,200],[5847,1772],[2813,1531],[-3098,-48],[1413,1747],[5238,-1828],[3524,-363],[-719,-1115],[6058,1441],[8645,-669],[-55,-2033],[3667,-1586],[9472,-142],[1282,1412],[-880,2012],[3009,1315],[6012,-2489],[1330,1261],[5159,-2117],[-805,-1749],[1810,-1125],[-2311,-1007],[2759,-1301],[-1324,-1398],[-3143,2100],[5440,-7788],[3876,-2235],[1124,940],[3033,6073],[2145,-2577],[3546,-617],[3283,1443],[5443,-2391],[762,2010],[3031,-719],[2152,277],[-955,3003],[1372,1252],[-2661,-274],[1180,1971],[4106,538],[-1031,1795],[3759,-1002],[4040,-134],[7603,-1516],[-5788,-1087],[6736,258],[-2457,-569],[264,-2725],[4048,3445],[6221,-970],[1054,-1902],[-2313,-280],[2813,-1688],[4226,-1327],[2574,-2681],[3570,269],[5836,1278],[5950,-333],[4697,-2309],[773,-2014],[-483,-3109],[2995,-1057],[347,-3011],[1472,-1143],[-79,2810],[2330,1597],[4955,416],[982,-653],[6586,-647],[2067,1424],[1449,-965],[425,-1812],[2799,-1137],[831,-1739],[2578,233],[1271,1302],[-1147,3188],[-1171,256],[905,2850],[5757,-825],[1994,-856],[7863,216],[2269,-1270],[5343,-1533],[3200,-2392]],[[999999,913406],[0,-23201]],[[999999,890205],[-1534,-1453],[-2578,-1298],[-2142,676],[-1582,1760],[-307,-1347],[-2798,1032],[1860,-1991],[1824,884],[126,-1953],[1446,-1316],[767,842],[1169,-2365],[396,-2517],[1497,-2075],[663,-2979],[-1249,-2174],[-3060,1343],[-2019,308],[-7159,-3859],[-3033,-1372],[-1366,-1833],[-765,369],[-1287,-2412],[-4957,-3714],[-715,-2781],[-1023,602],[-2100,3133],[-3025,-131],[-3260,-1581],[-1757,-2575],[-543,633],[600,2995],[-3521,-2288],[-182,-1409],[-1784,1169],[-1657,-100],[-1028,-1222],[-381,-3154],[-746,-1674],[-2397,-3393],[-504,-2194],[1408,-1841],[698,1066],[1377,-1536],[-1207,-1950],[65,-3236],[1259,-732],[221,-2698],[-801,-1113],[-1164,1112],[1139,1715],[-1527,-727],[-1121,-1834],[-988,-4334],[1045,-3589],[-1055,-1299],[-2647,50],[-1940,-2088],[-641,-2401],[504,-3875],[-1220,639],[-2714,-2157],[-404,-3368],[-1000,-2934],[-3766,-4979],[-567,2028],[-496,7096],[-739,2945],[-1330,11009],[-181,2867],[449,4287],[739,3691],[2072,2708],[690,1859],[-515,1670],[1830,304],[600,1306],[1512,33],[2295,2362],[2252,4166],[2798,2961],[2496,3111],[694,1588],[2693,2150],[2048,793],[-435,644],[1255,1886],[561,5617],[1087,1057],[2217,139],[-3169,1200],[-2567,-862],[-895,-4499],[-1713,-767],[-4518,-5384],[-1646,-680],[570,2293],[-1635,-408],[258,1985],[1206,2972],[-2125,-438],[-1321,1202],[-2796,-1000],[-1669,269],[-2193,-1887],[-139,-1232],[-2157,-2935],[-2451,-2372],[-1884,-3219],[-397,-1806],[2825,-997],[-19,-1008],[-1950,157],[-1242,-836],[-1806,825],[-1329,-1633],[-1338,517],[-2983,-896],[-572,1229],[3166,835],[-2152,1781],[-2274,191],[-2846,1268],[-2349,-1410],[325,-1479],[-1824,779],[-2064,-863],[-2715,1116],[-1682,-1532],[-1047,1274],[-6562,-256],[-3241,-2195],[-752,-1507],[-2972,-3159],[-661,-2361],[-4957,-5024],[-2696,-4895],[-4212,-4663],[-2536,-2423],[-14,-1255],[1651,-875],[2626,220],[-217,-4839],[1211,104],[-164,1818],[2052,-1077],[-1703,-2178],[1435,-111],[2308,1531],[352,2969],[1733,-752],[1077,498],[1777,-2752],[2931,-3724],[-614,-999],[-31,-3833],[875,-1126],[-328,-1526],[-1207,-1782],[-680,-2297],[-587,-4066],[114,-5627],[-963,-6354],[-2217,-3770],[-1031,-2986],[-1152,-1933],[-694,-3043],[-1809,-4295],[-2450,-3835],[-1837,-4040],[-743,-685],[-1087,-3191],[-979,-1832],[-3949,-4122],[-1526,-788],[-1253,1060],[-1126,44],[17,2549],[-1231,-1294],[-199,949],[-1768,-3728],[-1247,180],[-62,-2097]],[[863019,755336],[-639,-4],[-1947,-3493],[-132,-5065],[-3900,-4866],[-2046,-1504],[-482,-3402],[1088,-733],[1634,-2729]],[[856595,733540],[678,-2651],[1990,-5341],[385,-3155],[-196,-4087],[472,-9],[-428,-3275],[-569,-1872],[-1953,-479],[-186,-1366],[-1133,898],[-892,-399],[-229,-1566],[-855,-1345],[-216,1729],[-971,-1873],[-1017,-739],[-741,2127],[721,146],[-647,2703],[548,2920],[636,722],[-493,2354],[-145,3126],[-752,1049],[1001,881],[1057,-672],[-588,1703],[-312,3485]],[[851760,728554],[-733,572],[-704,-803],[-965,1436],[-1143,-1543],[-1026,1224],[718,743],[-1544,429],[1046,2533],[673,643],[-424,1222],[700,2469],[-134,1412],[-1627,1371],[-380,-847],[-768,2304]],[[845449,741719],[-712,-966],[-2983,-992],[-1936,-1821],[-1902,-2969],[-1351,-790],[-159,1121],[1593,1113],[385,1646],[-1508,-11],[671,2726],[788,626],[1316,3503],[-1155,1779],[-1901,351],[-1932,-3972],[-2466,-1945],[-1018,-2930],[-869,-1431],[-1706,-589],[-713,946],[-712,-547],[-630,-3017],[1269,-2617],[2569,-833],[415,-2027],[-379,-2189],[1381,-1223],[3612,4201],[2473,-2213],[1156,406],[1696,-747],[-1091,-3371],[-949,745],[-2619,-2142],[-858,-2545],[-1740,-1820],[-1737,-3316],[-634,-3276],[2779,-2505],[843,-4073],[1016,-3683],[-48,-2104],[1521,-1715],[7,-982],[1191,-1815],[95,-1163],[-2480,983],[-1260,1401],[-933,-828],[1475,104],[2626,-3934],[604,-2386],[-974,-450],[-1471,-1675],[-490,-1206],[-1032,196],[510,-1508],[1461,998],[1440,-1911],[1125,-644],[-1601,-2286],[1208,719],[-65,-2790],[-557,719],[-749,-741],[603,-715],[-527,-2187],[353,-1628],[-1399,-451],[-1420,-4205],[-132,-1555],[-725,-1311],[-534,-2520],[-568,-363],[-161,1398],[-518,-1334],[676,-1700],[-1162,-1656],[433,-303],[-73,-3765],[-1240,274],[-658,-2876],[-756,-553],[-213,-1512],[-1314,277],[-86,-2257],[-1190,-2425],[-448,22],[-2325,-2883],[-441,-2417],[-608,210],[-2093,-1555],[-840,583],[-950,-1188],[-562,821],[-271,-1341],[-800,71]],[[817405,638260],[69,-246]],[[817474,638014],[-64,-1208],[-701,1282]],[[816709,638088],[-1100,2071],[2,1576],[-803,-1277],[616,-1884],[65,-1758]],[[815489,636816],[-446,-704]],[[815043,636112],[-2304,-2379],[-1784,431],[-948,-1721],[-1628,-281],[-1249,-1763],[-434,735],[-640,-2841],[919,-2016],[-1078,-1508],[-920,2121],[-308,3020],[693,2068],[-1075,340],[-1284,-579],[-1671,2751],[126,-1382],[-1535,-968]],[[799923,632140],[-1563,-1322],[-682,-1991],[-1336,305],[194,-1571],[-654,-2643],[-1483,-2073],[-1006,-5763],[740,-2748],[1697,-3294],[-56,-1344],[1948,-4868],[1816,-3409],[543,51],[1791,-5021],[409,-626],[732,-3921],[607,-5093],[-88,-3419],[422,-1916],[59,-2111],[-628,274],[-56,-5457],[-589,-2301],[-461,-124],[-1526,-2258],[-2805,-3175],[-708,1553],[-291,-1645],[-1216,-501],[892,-1077],[-527,-1521],[-1275,2144],[1030,-2372],[-360,-1571],[-1519,2634],[782,-1938],[155,-1640],[-1854,-1799],[-1073,-2749],[-956,-187],[208,5975],[687,1747],[-180,986],[-1012,607],[-659,1430]],[[790072,566398],[-1359,1039],[-1124,107],[527,1691],[-527,1520],[-1054,-1380],[-77,3240],[-531,1458]],[[785927,574073],[-944,2940],[-150,-555],[-1405,2504],[-863,933],[-774,-418],[-1616,567],[276,4250],[-852,529],[-1773,-996],[201,-1822],[-350,-2106],[70,-3077],[-1005,-4194],[-390,-3396],[-895,-3376],[-11,-3470],[647,-3083],[917,596],[502,-1193],[156,-2617],[885,-2386],[484,-4894],[-822,1693],[738,-3201],[1651,-1937],[1334,26],[837,-2314],[837,-1377]],[[783612,541699],[665,-416],[540,-1834],[1244,-2000],[1204,-3997],[147,-2707],[-296,-3698],[254,-1472],[-39,-3481],[1035,-2089],[1129,-5081],[-117,-2121],[-1338,502],[-596,-711],[-342,1283],[-1750,1832],[-3976,6100],[12,2182],[-1624,4224],[-280,4064],[-728,5542],[-25,2349],[-623,2712]],[[778108,542882],[-1175,2576],[-276,2837],[-662,98],[-855,3055],[-1311,2704],[-438,-983],[-508,1450],[370,5139],[920,5332]],[[774173,565090],[-389,-921],[-272,3797],[586,1843],[183,3583],[-292,869],[167,2884],[-335,5549],[-918,3387],[-266,-509],[-137,3044],[-800,4132],[-283,6023],[-350,853],[137,2596],[-716,387],[-549,3193],[-578,1513],[-171,-1697],[-796,-2767],[-2387,-2339],[-1038,-2644],[-156,1839],[-1084,-1273],[-138,2159],[-643,-1649],[162,2929],[-1378,-2265],[1014,9200],[-439,3746],[-943,3836],[-129,2596],[-321,-2297],[-622,754],[-590,2030],[922,-776],[481,1199],[-1766,3658],[-1001,98],[180,1793],[-956,-486],[-1107,2940]],[[756455,627897],[-745,2269],[-133,3021],[-509,3223],[-957,3887],[-914,-1604],[-571,-101],[-974,3181],[-444,-2263],[429,-2924],[-997,-2539],[-443,340],[384,1596],[-710,-793],[-200,2161],[-194,-2394],[-1273,-1554],[-722,898],[-118,1306]],[[747364,635607],[1,-2601],[-852,-413],[-287,3185],[-158,-2739],[-1466,204],[387,2639],[-1439,-2880],[-1605,-905],[-669,-1564],[322,-3179],[-625,-2292],[-1308,-2333],[-1957,-1342],[-320,1202],[-825,-1629],[774,34],[-1863,-2969],[-1852,-4934],[-1250,-1320],[-1266,-2730],[-1681,-1985],[-865,-2002],[-64,-2229],[-1380,-1365],[-1322,45],[-854,-3428],[-923,809],[-980,-1091],[-667,-3773],[311,-2939],[-150,-2166],[642,-5041],[-315,-3976],[-1031,-4156],[-290,-2450],[264,-2242],[-29,-5179],[-1243,-99],[-1096,-3690],[-46,-2456],[-1550,-969],[-637,-1268],[-367,-3000],[-1507,-1814],[-1530,1949],[-1148,2935],[-637,3255],[-227,2814],[289,-30],[-1178,5107],[-552,3422],[-1464,4122],[-1184,6042],[-277,3497],[-801,4900],[-1203,3437],[-48,1909],[-1266,3894],[-385,2403],[-504,6884],[-793,6287],[375,2003],[-475,-270],[-98,3224],[-366,1844],[593,4338],[-187,3282],[-557,2042],[1136,1408],[-1331,-18],[436,1632],[-1093,1287],[-748,-2169],[602,-1730],[-663,-2224],[-2752,-2469],[-848,9],[-1645,2098],[-3107,6530],[-70,1117],[814,-592],[2502,1702],[731,2356],[-2155,-1252],[-1191,530],[-1653,2023],[-620,2260],[998,1663],[-1505,-1512],[-194,1543]],[[689347,646059],[-1380,-275],[-997,2156],[-383,3443],[-1301,622],[-13,2164],[-750,2068],[-2080,-1304],[-2509,-284],[-326,-730],[-1409,885],[-1653,117],[-182,-843],[-2552,260],[-715,-710],[-1588,19]],[[671509,653647],[-584,340]],[[670925,653987],[-336,-554],[-2079,1067],[-2911,718],[-1583,83],[-689,813],[-1344,156],[-2721,1248],[-640,3435],[-339,3164],[-470,1093],[-1269,654],[-1961,-1320],[-2095,-2493],[-697,-283],[-1105,1112],[-1504,172],[-697,1289],[-2120,2252],[-599,1737],[-1237,1231],[-1012,122],[-1076,1697],[-1120,5518],[-557,497],[-71,1620],[-1335,2969],[-271,1643],[-1435,-1005],[-1391,1647],[-1410,-2041]],[[634851,682228],[-1577,121]],[[633274,682349],[457,-2431],[-1161,-922],[906,-364],[1086,-4814]],[[634562,673818],[920,-3459],[65,-1391],[1223,-1372],[466,-1847],[1614,-2085],[552,-2512],[-426,-1742],[1462,-6068],[685,-1762]],[[641123,651580],[-116,3883],[668,3180],[720,1018],[780,-1486],[-161,-2238],[324,-2232],[-483,-2842],[-445,-362]],[[642410,650501],[117,-1580],[718,-322]],[[643245,648599],[938,-1782],[958,59],[1103,944],[3459,-460],[1399,1192],[972,3153],[976,1370],[1179,2705],[1163,1752],[386,1592]],[[655778,659124],[971,1567],[-367,-4008]],[[656382,656683],[251,-3978]],[[656633,652705],[701,-3015],[1609,-3244],[3773,-1654],[2659,-6310],[800,-412],[-65,-1712],[-1190,-4272],[-1321,-2287],[-1171,-4182],[-1032,968],[-669,-1932],[-408,-3776],[268,-3494],[-1764,-678],[-1448,-1868],[-290,-2497],[-779,-1274],[-2198,-637],[-622,-1527],[112,-1209],[-643,-2030],[-2766,-198],[-1273,-1454],[-1457,-661]],[[647459,603350],[-2105,-2103],[-427,-1994],[121,-1786],[-1705,-1888],[-2991,-1769],[-1000,-1109],[-2270,-1263],[-838,-1074],[-1055,-2407],[-1884,-13],[-1618,-2289],[-1719,-1162],[-3143,-751],[-1718,-3098],[-1169,8],[-721,-877],[-1190,-312],[-1263,1318],[-676,2536],[141,2209],[-538,2199],[-189,3222],[-844,6515],[340,2236],[-112,2013]],[[618886,601711],[-279,2163],[-876,2284],[-248,1852],[-1511,2670],[-1446,4696],[-315,2393],[-992,3988],[-1884,3025],[-1298,1491],[-1444,4696],[148,1236],[-442,2149],[300,3028],[-246,2235],[-1996,6760],[-1025,1625],[-1046,630],[-1006,3130],[-89,2791],[-1751,4821],[-747,2903],[-1857,4962],[-1113,3569],[-1567,673],[454,2126],[475,5014]],[[597085,678621],[63,1193]],[[597148,679814],[-192,-460]],[[596956,679354],[-467,-1225],[-935,-7432],[-499,-1492],[-1277,1679],[-1424,3081],[-477,2994],[-985,2658],[-432,2679],[-572,-2033],[570,-1448],[185,-2335],[740,-2530],[1803,-3952],[7,-1722],[954,-3306],[183,-2372],[1684,-5675],[1747,-7204],[1638,-3184],[-675,-101],[-50,-2833],[486,-2940],[1477,-1881],[1783,-3744]],[[602420,635036],[154,-2431],[791,-2373],[-51,-6311],[153,-3192],[619,-4513],[1252,-1565],[777,-1816],[1133,-1448]],[[607248,611387],[839,-3424],[642,-4135],[434,-4787],[1352,-4717],[217,2046],[945,-2703],[2701,-2333],[1339,-3775],[1630,-2343],[428,-2222],[1103,-2063],[890,-922]],[[619768,580009],[814,-3073],[-382,-1306],[-1314,-1363],[-721,-1393],[1033,487],[929,-514]],[[620127,572847],[1686,-4239],[1482,-2098],[1546,39],[2427,2365],[2079,-533],[2333,2536],[1706,-205],[1820,1086],[734,-381]],[[635940,571417],[3254,1605],[1895,2692],[1285,-906],[-474,-2933],[157,-4022],[677,-1601],[-1262,-302],[-292,-5376],[-1098,-3454],[-908,-3824],[-697,-1405],[-252,-1795],[-1146,-3964],[-832,-4840],[-1111,-4024],[-1153,-3209],[-719,-2700],[-3046,-7176],[-2299,-4802],[-3141,-3941],[-1632,-2482],[-2403,-4558],[-4133,-9448],[-1242,-4279]],[[615368,494673],[-405,-1018],[-1381,-926],[91,-1009],[-773,-2048],[-1172,-882],[-297,-3331],[-1735,-7274],[-747,-1268]],[[608949,476917],[-1118,-7022],[152,-2688],[1662,-3242],[205,-861],[-716,-2926],[424,-2925],[-381,-2561],[409,-2957],[528,-1478],[396,-4278],[1888,-3258]],[[612398,442721],[412,-1168],[-581,-3972],[358,-3985],[-123,-2888],[260,-850],[-99,-4901],[280,-6374],[478,422],[47,-1919],[-603,-1920],[-164,-2121],[-1250,-2997],[-734,-2703],[-1673,-2115],[-439,-1068],[-2609,-1600],[-2502,-2944],[-2548,-6240],[-1877,-2196],[-1954,-3844],[-534,-55],[-141,-3859],[771,-1973],[792,-5004],[321,-4761],[307,1954],[227,-4967],[-569,-4947],[476,-156],[-288,-2054],[-784,-2193],[-1524,-1658],[-3500,-2605],[-1542,-2272],[-561,-2131],[718,-1564],[295,1093],[-191,-4536]],[[591350,345650],[-976,-8001],[-692,-2499],[-1410,-1869],[-1230,-2613],[-2907,-9432],[-3980,-7845],[-2765,-4500],[-2175,-2769],[-1800,-1412],[-1222,286],[-976,-1776],[-1765,222],[-488,-1157],[-3449,1089],[-882,-569],[-1984,421],[-856,-350],[-1269,-1798],[-2024,47],[-1473,-583],[-1415,-1911],[-1071,192],[-1491,2389],[-741,-83],[-63,1516],[-1269,-476],[314,1781],[-566,2762],[-1139,3520],[1110,1039],[167,3138],[-278,2251],[-1482,4286],[-1356,5446],[-664,4126],[-1396,4656]],[[545687,335174],[-2024,3861],[-1048,3432],[-1038,6330],[-341,3509],[-22,4103],[-932,4925],[-77,5455],[-196,1855],[340,1573],[-567,3037],[-968,2502],[-1452,5041],[-784,4337],[-1972,7452],[-1007,2286],[-889,3195],[-91,4457]],[[532619,402524],[211,3230],[-189,5168],[603,1172],[868,5904],[750,7107],[964,2430],[238,1493],[1205,1513],[1023,4192],[173,4493],[-351,2493],[-505,1261],[-917,4251],[-585,3881],[1001,2138],[54,1881],[-1434,6741],[-108,1642],[-839,2159],[-608,2949],[2127,1349]],[[536300,469971],[-1824,-720],[-550,1349]],[[533926,470600],[-101,2571],[-441,1898]],[[533384,475069],[-669,2598],[-1798,3848]],[[530917,481515],[-2174,5350],[-1634,2931],[-1279,3647],[-730,3520],[785,-1915],[568,200],[-1274,1777],[-676,3495],[876,800],[445,1316],[14,3790],[1375,-1447],[568,893],[-1264,598],[-615,1518],[814,145],[-75,2698]],[[526641,510831],[-569,636],[1169,4669],[-17,2234]],[[527224,518370],[410,4588],[-1090,4260],[510,326],[-711,1263],[-162,-852],[-1181,1003],[-228,2738],[-955,-164],[-51,1357]],[[523766,532889],[-895,902],[165,-2073],[-2802,-59],[-753,-890],[-2602,-632],[-1358,2112],[-568,2855],[7,1616],[-1458,3700],[-1193,1909],[-849,372],[-3944,-250]],[[507516,542451],[-3010,-903]],[[504506,541548],[-1210,-755]],[[503296,540793],[-658,-1653],[-1917,-314],[-1691,-1520],[-1246,-1624],[-2336,-1456],[-1009,-1294],[-1103,989],[-1987,944]],[[491349,534865],[76,235]],[[491425,535100],[187,14]],[[491612,535114],[-606,1212],[-305,-1213],[-2146,1061],[230,-471],[-2396,-544],[-344,387],[-2473,-1142],[-2803,-2207],[-1728,-1701]],[[479041,530496],[-1983,1414],[-2426,2753],[-3179,6061],[-1413,1377],[-177,918],[-1229,1322],[-600,1294]],[[468034,545635],[-627,1078],[-2091,1764],[-68,1655],[-1030,1131],[-210,1711],[-533,410],[-400,4945]],[[463075,558329],[65,719],[-1077,2776],[-91,1710],[-2047,1899],[-970,4048],[-743,50]],[[458212,569531],[-31,1196],[-940,446],[-116,4303],[-1068,-1067],[-387,1162],[-877,110],[-124,981],[-1091,1251]],[[453578,577913],[-143,4202]],[[453435,582115],[-172,1641],[746,-225],[3107,1067],[-1937,-207],[-848,-564],[-338,1387]],[[453993,585214],[-1143,4834],[-540,1407],[-1021,678],[1080,989],[843,2204],[856,3225]],[[454068,598551],[-2,2657],[526,3789],[744,3670],[134,2026],[-151,3752],[-357,2856],[-836,2125],[642,2519],[202,2611],[-609,2515],[-535,-108],[-705,2678],[-478,-1659]],[[452643,627982],[108,3383]],[[452751,631365],[217,3098]],[[452968,634463],[1591,4114],[411,2982],[1124,3861],[-259,562],[2390,4173],[508,1913],[170,3155],[1057,5033],[2329,2852],[888,4144]],[[463177,667252],[223,1310]],[[463400,668562],[630,1531],[2675,1275],[1544,1497],[970,1965],[1651,2081],[1760,4409],[516,1778],[40,2004],[-618,1602],[185,4187],[1281,3920],[283,2880],[1804,3642],[819,1109],[2053,1575],[1837,1948],[1522,4781],[1190,5982],[1797,693],[-167,-933],[1390,-2749],[1410,-709],[1768,702],[1353,-242],[650,996],[368,-1656],[1723,-140]],[[493834,712690],[851,-59],[1603,1600],[1163,1802],[1365,1144],[1317,231],[1297,2140],[2062,1528],[3711,480],[1053,1089],[2241,662],[2719,1],[1852,-1309],[2290,1557],[660,874],[1225,-985],[768,1024],[1961,-1398],[1851,479]],[[523823,723550],[3088,2388],[1412,-797],[600,-2808],[1782,2018],[202,-1175],[-1670,-3263],[181,-2584],[1149,-1501],[322,-2332],[-1626,-4120],[-1306,-1974],[262,-2142],[1566,-1988],[1005,287],[328,-1859],[839,-398]],[[531957,701302],[2153,-1916],[1316,-341],[1472,673],[2649,-1382],[767,-1009],[1843,-710],[507,-1371],[381,-2980],[582,-1365],[1159,-959],[1829,-295],[1577,-789],[2336,-1802],[2073,-2885],[986,-14],[1172,1187],[1215,3497],[-624,4378],[542,2377],[1388,2141],[2819,2116],[1532,-113],[2509,-1775],[544,-2399],[1420,-326],[922,-886],[1540,40],[947,-786],[349,-1352]],[[569862,692256],[644,-843],[1419,641],[3763,-1440],[1999,-1662],[1520,-278],[1548,-1304],[3675,3716],[1685,31],[140,763],[1312,-790],[1013,493],[-328,-1475],[919,-1183],[616,967],[777,-1110],[3609,665],[821,839]],[[594994,690286],[776,1554]],[[595770,691840],[558,1842],[1195,7038]],[[597523,700720],[1398,5619],[100,1280],[912,2257]],[[599933,709876],[-92,3523],[-496,2060],[356,2044]],[[599701,717503],[-227,2330],[1049,2068],[-388,1491],[-1421,-1858],[-2600,1111],[-2518,-3569],[-2500,-866],[-1158,875],[-989,2084],[-1859,1574],[-1968,383],[-446,-3290],[-2207,-910],[-1516,1425],[-292,1755],[-2040,702],[-1800,-814],[1629,2100],[-2481,-56],[517,855],[-878,1334],[-392,1769],[429,1724],[-2616,1769],[619,2087],[226,-1250],[1399,-17],[-930,1741],[694,1050],[-921,2402],[403,1604],[-1983,-566],[189,3096],[1547,2430],[1518,328],[530,-803],[4388,617],[-270,1223],[2464,637],[-1334,422],[-887,1174],[285,1265],[2143,-416],[1182,273],[1292,-664],[1236,135],[564,1259],[2357,2426],[2985,1706],[3804,-360],[711,631],[809,-1983],[2094,-273],[354,-1516],[918,-972],[745,598],[801,-1061],[3652,-1540],[2904,1078],[2330,-859],[1929,1482],[1529,1812]],[[615305,750685],[703,2681],[-762,4084],[-1812,2395],[-2384,2111]],[[611050,761956],[-3503,5143],[-1489,780],[-916,1654],[-1222,217],[-574,1401],[-1539,916],[807,967],[1961,517],[61,1641],[959,2333],[1327,253],[-2016,3233],[2041,163],[-174,885],[2375,1733],[-272,967],[-2726,-1051]],[[606150,783708],[-1863,-100],[-566,-935],[-2945,-1529],[-1257,-203],[-1519,-2044],[-138,955],[-1058,-1485],[481,-2897],[1487,-2311],[1701,843],[1124,-353],[-505,-1944],[-1453,-356],[-1105,552],[-1069,-1754],[-1030,27],[-2241,-2485],[-1276,983],[290,3224],[-1768,1484],[-1141,330],[3214,3218],[-1285,1355],[-2015,-546],[-1936,1428],[2217,1723],[-2905,291],[-2044,-667],[-1604,-4060],[-1715,-1092],[290,-2503]],[[582516,772857],[-412,-2468],[-1415,-508],[-1,996],[-1118,-3732],[-167,-3279]],[[579403,763866],[-333,-2091],[-921,37],[-569,-1241],[-112,-2585],[-1122,-1669],[1471,-2956]],[[577817,753361],[922,-2978],[1975,-1402],[-769,-1514],[-1690,631],[-1868,-637],[-671,-1694],[-1350,-1121],[-1581,-2504],[142,1418],[1495,1848],[-1907,-91],[-185,684]],[[572330,746001],[-2596,1587],[-865,-812],[-878,534],[-1096,-1325],[-888,141],[289,-1951],[-562,-1154],[-979,-43],[-1896,1653],[-103,-2717],[1934,-4432],[-1249,-179],[352,-1349],[-1025,-832],[1823,-1358],[1983,-2288],[246,-3350],[-1319,1783],[-1512,-783],[1258,-2596],[-910,-630],[-1211,1234],[748,-3118],[-33,-2888],[-564,1527],[-1122,-499],[-821,1937],[-522,-1728],[-860,2036],[-32,2726],[-1204,1855],[738,2029],[1170,779],[3043,-2191],[635,1290],[-2020,1555],[-2637,-694],[-998,375],[-926,3696],[-1330,1888],[-832,2265]],[[555559,739974],[-416,1979],[-1020,986],[-388,2442],[323,1843],[-57,2912],[380,2149],[-653,484]],[[553728,752769],[-2291,3340]],[[551437,756109],[-2361,2750]],[[549076,758859],[-229,244]],[[548847,759103],[-1894,2690],[-1415,895],[-1134,-140],[-2396,4366],[966,90],[-1599,2575],[-113,2217],[-1505,1523],[-964,-2975],[-934,1614],[-143,2422]],[[537716,774380],[394,419]],[[538110,774799],[-254,1086],[-3761,-1925],[-135,-1212],[827,-1620],[-764,-1455],[411,-2954],[819,-1357],[2425,-2509],[1239,-5224],[2377,-3774],[841,-702],[2209,32],[520,-1072],[-385,-1914],[3030,-2211],[2365,-2411],[1475,-3261],[-395,-1679],[-738,685],[-591,2033],[-2603,1054],[-1106,-3545],[188,-1308],[1436,-1530],[167,-2267],[-1710,-1678],[-38,-1811],[-1357,-2768],[-923,-16],[688,4582],[624,276],[-481,3522],[-920,3771],[-2060,1474],[-515,2544],[-1842,941],[-1025,2420],[-1791,48],[-1272,1338],[-2760,4846],[-947,804],[-1633,3039],[-1835,6419],[-2107,1774],[-1454,611],[-1901,-2982],[-1634,-900]],[[520814,764013],[-644,-421]],[[520170,763592],[-2132,-3121],[-1050,-574],[-1970,925],[-964,1280],[-1197,-341],[-1600,1220],[-2205,-2369],[-575,-1646],[443,-2868]],[[508920,756098],[102,-2884],[-3237,-3892],[-2916,-1335],[-3078,-7027],[-701,-2109],[340,-2709],[1129,-1798],[-1619,-1917],[-737,-1681],[-487,-3383],[-1404,-117],[-1306,-1945],[-1083,-2887],[-6054,-162],[-853,-1254],[-1382,-490],[-1261,-2357],[-1154,963],[-1254,4539],[-1089,1420],[-1449,-88]],[[479427,724985],[-1189,-1029],[-2121,685],[-1111,-528],[510,2361],[-186,6019],[-923,8],[215,1746],[-939,-71],[276,3599],[629,1210],[726,3773],[642,5036],[-618,4755],[280,646]],[[475618,753195],[240,1973],[-656,3107],[-856,1057],[1004,2118],[1736,621],[-23,833],[1553,1093],[1211,-1006],[4434,-72],[3174,-988],[2552,615],[1552,-876],[474,491],[1495,-749],[1507,470]],[[495015,761882],[860,927],[664,5901],[458,5762],[873,-1292],[-736,2528],[-319,3379],[-1778,1205],[-1140,3840],[615,875],[-1466,8],[210,941],[-4092,2172],[-1143,-86],[-1018,1283],[970,772],[-1087,2192],[4136,1783],[1499,-1801],[684,661],[2971,25],[-525,906],[-49,2351],[-759,2852],[1660,-21],[334,-1732],[2708,-540],[1613,898],[-640,1509],[2941,1748],[965,1505],[-38,2885],[926,1491],[1701,631]],[[507013,807440],[2292,1662]],[[509305,809102],[2434,52]],[[511739,809154],[-2159,914],[2038,411],[-655,1187],[1488,2953],[544,2967],[3844,3539],[2094,202],[1059,-942]],[[519992,820385],[243,2365],[2012,54],[1342,-1044],[352,2136],[998,304],[-73,3208],[-750,1921]],[[524116,829329],[-57,1149]],[[524059,830478],[-126,2562],[-1344,1075],[88,5966],[1409,-658],[280,1359],[-1356,754],[930,1534],[2599,718],[1134,2065],[1586,915],[-26,-2916],[-670,-3689],[1564,-587],[29,-1339],[-1493,-489],[-377,-2060],[-1645,-2204],[-381,-2688],[699,-660]],[[526959,830136],[112,-716]],[[527071,829420],[1105,-1889],[1633,-1020],[783,373],[-265,-2274],[1338,-301],[1977,1326],[1289,1771],[1259,-333],[1165,-1601],[767,73],[393,-1776],[1068,-720]],[[539583,823049],[660,-355]],[[540243,822694],[-380,1107]],[[539863,823801],[-496,92]],[[539367,823893],[109,450]],[[539476,824343],[5485,2015],[1038,1561],[1950,1041],[2949,643],[962,-2413],[851,-485],[1745,652]],[[554456,827357],[1028,2738],[1516,437],[1054,1728]],[[558054,832260],[-112,-611]],[[557942,831649],[-735,-1191],[1650,-280],[95,1022]],[[558952,831200],[37,968]],[[558989,832168],[-528,4734]],[[558461,836902],[70,4464],[1826,4428],[2294,908],[2035,-3759],[1789,-482],[1311,1874],[-224,3233]],[[567562,847568],[574,2866],[-2116,39],[-932,3317],[174,1629],[2461,1641],[2954,287],[182,699],[4070,-1117],[2883,200]],[[577812,857129],[4,1425],[2592,616],[556,1013],[2709,-748],[-1115,1906],[-1811,-23],[-1183,1090],[-362,1789],[-1987,-836]],[[577215,863361],[-1544,15],[-4404,-1218],[-3649,-1723],[-2442,-333],[-1657,1361],[-1123,-1106],[339,2081],[-3191,1279],[-210,2198],[682,3698],[-972,2359],[-423,3752],[1520,2466],[-294,978],[2152,629],[590,1999],[1990,1471],[2860,3668],[777,1693],[2028,351],[166,3667],[-3312,1931]],[[567098,894577],[-2925,-414],[-2045,636],[-312,-1452],[-1912,-1123],[42,-1465],[-1229,-2086],[1059,-2047],[-2102,-3527],[-3913,-2313],[-2077,-1772],[-398,-1673],[-1577,-387],[394,-1363],[-1296,-886],[-1222,-5185],[334,-5184],[1958,-658],[2309,-3023],[509,-1909],[-2795,-2357]],[[549900,856389],[-432,1148],[-1213,-340],[-2217,687],[-1193,-972],[2137,-11],[1086,-1029],[1875,337]],[[636756,779239],[-2981,-3555],[-1749,-1226],[-1238,-4225],[-913,-950],[1684,-3929],[410,-2866],[-127,-2813],[3082,-7052]],[[634924,752623],[1483,-3216],[333,-1632],[1526,-2620],[596,-43],[1043,-1761],[-1242,219],[-1021,-725],[-977,-6644],[-517,364],[-452,-1888],[50,-2251]],[[635746,732426],[589,-4549],[1081,-1013],[1835,-530],[1118,-2331],[1626,-1606],[1788,-759],[1189,43],[3289,1463],[1655,-299],[-155,3112]],[[649761,725957],[-252,3462],[172,5546],[-502,2047],[-1522,329],[219,2035],[1021,-365],[-322,2147],[-1636,19],[-457,2880],[583,3788],[560,-1262],[1306,-39],[412,-905],[1705,163],[924,1173],[-328,1791],[-1381,1931],[-690,3387],[-1895,16],[-540,-697],[-300,-4539],[-1022,3379]],[[645816,752243],[-89,1897],[374,3908],[-1766,535],[-958,1824],[-890,93],[17,1826],[-1308,4208],[-1388,787],[-93,1517],[1563,280],[1898,-579],[-1349,1662],[-49,1000],[1043,2237],[3098,241],[1859,-395],[-1185,1427],[1004,3666],[97,2829],[-706,1690],[-1202,215],[-1105,-895],[-2520,1603],[-2108,-1367],[-1165,-1452],[-1812,-682],[-320,-1079]],[[579890,406773],[-220,141]],[[579670,406914],[-1105,-546],[-671,-1286],[-690,-10],[-2147,-6749]],[[575057,398323],[576,1214]],[[575633,399537],[498,1027]],[[576131,400564],[826,2210],[951,844],[287,1338],[1698,181],[628,1169],[-631,467]],[[596829,424051],[8,920]],[[596837,424971],[-448,7467],[713,2757]],[[597102,435195],[-55,1606]],[[597047,436801],[-915,2270],[165,1707],[-397,4516],[-565,1769],[-903,1399],[-117,-964]],[[594315,447498],[86,-2168],[765,-2509],[-181,-705],[342,-6592],[-719,-2188],[41,-1803],[711,-3505],[-25,-2578],[835,-2199],[-59,-2417],[712,855],[1187,-2077],[-633,3686],[-548,753]],[[583319,439012],[-188,1258],[-1007,-1653],[65,-1400],[914,550],[216,1245]],[[590316,457091],[-1354,2401],[-631,-116],[447,-1971],[1538,-314]],[[581104,494978],[-351,228],[-450,-2385],[-164,-2606]],[[580139,490215],[167,89]],[[580306,490304],[1242,2470],[-300,1659]],[[581248,494433],[-144,545]],[[594373,505981],[-82,25]],[[594291,506006],[-1283,3],[-822,1245],[-139,-1043],[-2578,-1442],[-719,-834],[122,-1120],[-754,-2779],[125,-863]],[[588243,499173],[42,-439]],[[588285,498734],[229,-485],[-558,-4978],[429,-5130],[1171,3050],[1584,-1374],[713,620],[968,-717],[1092,2009],[-1480,404],[689,756],[-629,442],[819,952],[209,1512],[615,-40],[118,1925]],[[594254,497680],[365,913]],[[594619,498593],[45,2690],[383,818],[1656,983],[-266,1090],[-1202,-1532],[-841,2220],[-21,1119]],[[550098,499048],[149,1643],[-772,44],[623,-1687]],[[582340,501712],[626,1212]],[[582966,502924],[-236,1073]],[[582730,503997],[-387,11]],[[582343,504008],[-409,-359],[-361,-2723],[767,786]],[[580912,453401],[-726,882],[-1388,-3483],[-94,-2068],[1006,317],[1202,4352]],[[586662,453459],[-41,293]],[[586621,453752],[-1610,6282],[-212,3670],[-1086,2711],[-477,-209],[-675,1517],[627,2047],[-544,2923],[168,1754],[-569,1196],[94,2477]],[[582337,478120],[31,410]],[[582368,478530],[-801,3341],[-183,2968]],[[581384,484839],[-285,99]],[[581099,484938],[-369,-5798],[532,1280],[-362,-2874],[-21,-3186],[709,-2918],[-506,-1768],[893,-4769],[1766,-3432],[410,-3397],[794,-1827]],[[584945,456249],[-19,-482]],[[584926,455767],[-303,-1397],[928,-402],[684,-1335],[427,826]],[[593018,514450],[-202,523],[-1153,-1089],[-1262,-151],[1988,-1771],[-373,1661],[1002,827]],[[586953,517904],[-123,-430]],[[586830,517474],[-2165,-4070],[-5,-1348]],[[584660,512056],[322,-1170],[1043,2878],[1196,2183],[-268,1957]],[[605708,543686],[-517,-515],[-73,-2474],[733,2227],[-143,762]],[[500755,544260],[92,2515],[-132,4775],[-564,-668],[-1498,2472],[-1767,4265],[904,-3637],[1675,-2135],[-283,-1706],[850,-738],[478,-1638],[-200,-2189],[-850,-921],[-1222,72],[2049,-2460],[468,1993]],[[601901,519752],[-90,1900],[-625,906],[-572,2734],[-131,6867],[-621,-468],[-287,-4607],[330,-2413],[1272,-3754],[308,-1698],[416,533]],[[513014,566127],[-597,1548],[-7,-3130],[530,-1266],[74,2848]],[[790472,578337],[-362,1765],[-571,235],[-705,2460],[-782,165],[298,-1542],[1254,-2269],[868,-814]],[[540322,581615],[423,1277],[-906,-197],[-187,-1535],[670,455]],[[603570,574593],[283,-961],[568,2872],[-365,1103],[-1012,-223],[-261,-2127],[787,-664]],[[785773,614477],[-978,844],[245,-1141],[733,297]],[[589539,639047],[271,-551],[1877,3887],[-268,2056],[-1140,-4893],[-926,179],[-1993,-3425],[-939,-3194],[577,837],[905,2959],[1140,2091],[496,54]],[[752585,676072],[-1025,1705],[79,-2453],[946,748]],[[824182,677333],[-1536,690],[124,2955],[-646,-2607],[368,-1217],[1202,-1253],[488,1432]],[[835004,688893],[-392,-39],[-672,2672],[-642,-830],[-27,-1781],[1157,-714],[576,692]],[[750960,685556],[1857,1866],[-196,502],[-1819,-456],[158,-1912]],[[632245,686535],[-2077,1346],[-107,-1133],[2184,-213]],[[738577,687953],[-1352,630],[-67,-767],[1247,-724],[172,861]],[[748104,693080],[-970,734],[-1117,-439],[1587,-1436],[500,1141]],[[831685,699001],[-162,1684],[-748,-1323],[910,-361]],[[749194,703001],[333,-1261],[388,1327],[-721,-66]],[[621884,698374],[-912,2321],[-189,-2590],[862,-635],[239,904]],[[741047,689360],[-98,1041],[-960,-2790],[1058,1749]],[[731194,691121],[-361,1115],[-731,-517],[1092,-598]],[[827614,691459],[-1739,1048],[424,-1368],[1315,320]],[[770743,711929],[-1115,128],[456,-1016],[659,888]],[[771908,711114],[-346,1481],[-599,-1257],[945,-224]],[[779868,722801],[-1328,2011],[-885,400],[-850,-1816],[857,-1378],[2071,-529],[135,1312]],[[626850,728317],[-640,381],[-219,2714],[-698,0],[719,-6148],[475,-543],[895,1727],[-532,1869]],[[619412,731894],[916,848],[-73,1920],[-2128,-401],[-480,-1454],[1470,-136],[295,-777]],[[592937,734903],[-423,1618],[-409,-2100],[1042,-1478],[390,1123],[-600,837]],[[750778,745730],[-534,277],[670,-3123],[-136,2846]],[[624971,744746],[897,-2169],[941,273],[-774,1684],[-1064,212]],[[688863,749129],[401,-1223],[890,346],[-1291,877]],[[829457,701243],[710,1093],[-1035,1086],[-870,-1953],[24,-1928],[1171,1702]],[[620169,704535],[573,1320],[-1115,1822],[542,-3142]],[[868915,771622],[-4,576]],[[868911,772198],[-637,875],[-1411,-56]],[[866863,773017],[-184,-432]],[[866679,772585],[179,-2794],[823,-1261],[1234,3092]],[[728146,775844],[-613,3476],[-1063,599],[-286,-2349],[1962,-1726]],[[719418,781676],[-2373,-910],[-4556,241],[-1862,1081],[-1109,-477],[-2226,87],[-1475,-1921],[-313,-1431],[-1495,-3136],[1878,-3895],[-53,3183],[525,674],[-32,2094],[1371,615],[324,1592],[1433,1380],[576,-606],[1742,282],[2404,-597],[904,269],[1633,-929],[2424,160],[922,2078],[-642,166]],[[598330,772830],[-1552,3481],[-238,1599],[-2940,519],[1851,-1185],[1771,-2191],[248,-1615],[860,-608]],[[519093,779803],[-793,572],[-699,-995],[1492,423]],[[598060,786080],[-477,2063],[-370,-1496],[-2947,-274],[-691,-4032],[1396,3312],[1118,595],[1023,-674],[948,506]],[[527029,786288],[-240,234]],[[526789,786522],[-1282,617]],[[525507,787139],[1032,-944]],[[526539,786195],[490,93]],[[825635,792558],[1620,3199],[-490,1285],[-1798,-2969],[668,-1515]],[[757740,790146],[-2251,-998],[622,-1428],[1629,2426]],[[734765,788675],[-2596,2000],[-262,2671],[340,1138],[1998,1271],[-1664,2431],[446,1097],[-1459,-559],[2041,-2300],[-1745,-2249],[-101,-3485],[-1101,-120],[3342,-3218],[761,1323]],[[760553,795780],[-1500,966],[-1096,-395],[1480,-1392],[1116,821]],[[592130,795130],[-1395,3067],[-92,-920],[-2031,776],[2414,-2351],[1104,-572]],[[662809,774782],[-63,-283]],[[662746,774499],[-880,-3290],[-81,-2711],[618,-374],[836,1696],[-121,4023]],[[663118,773843],[104,375]],[[663222,774218],[640,1702],[1079,-1224],[186,-2401]],[[665127,772295],[-179,-732]],[[664948,771563],[-498,-1754],[1691,-3336],[620,1333],[58,2571]],[[666819,770377],[23,2136]],[[666842,772513],[-175,2227],[-643,9],[218,1988],[1582,-394],[2240,2499],[162,813],[-1538,1592],[-1140,68],[-663,-1693],[1804,3],[-214,-1962],[-2400,103],[-1211,-2325],[-46,1648],[-1706,-751],[-303,-1556]],[[742708,752661],[-166,982],[-1185,542],[-376,-1675],[1727,151]],[[703585,752117],[-1682,832],[-17,-1401],[1699,569]],[[660075,754430],[-215,659]],[[659860,755089],[-770,252],[-497,-1984]],[[658593,753357],[-112,-1155]],[[658481,752202],[812,-1186],[1311,750],[-529,2664]],[[553592,755111],[-214,-628]],[[553378,754483],[495,-417]],[[553873,754066],[-86,1194]],[[553787,755260],[-195,-149]],[[717506,757030],[-149,861],[-2533,-379],[-3085,-1239],[102,-788],[1829,-813],[1647,-51],[2189,2409]],[[516297,816124],[-1241,1845],[-1057,-3142],[1265,-546],[1033,1843]],[[703851,819125],[51,1726],[-1161,44],[1110,-1770]],[[634409,825632],[1183,-1358],[145,-1724],[943,-1659],[1879,699],[-2208,287],[365,2372],[1415,1722],[-1964,-1244],[-1308,1336],[874,1313],[401,3009],[1077,272],[1377,1644],[3786,1378],[-3237,-375],[-2032,-734],[-1385,-1939],[-318,-1994],[-914,-462],[-79,-2543]],[[554900,827208],[1780,1468],[-1158,167],[-1782,-2619],[1160,984]],[[787591,822480],[-1194,3030],[438,1154],[-296,2720],[582,2473],[-947,5896],[-1558,234],[-2102,-1663],[761,-2664],[18,2978],[1444,772],[1153,-552],[976,-4508],[-665,-3079],[514,-1751],[-596,-2483],[754,-2311],[277,-3377],[441,3131]],[[713185,829568],[-35,-687],[2794,-326],[857,1577],[-1329,746],[-2287,-1310]],[[607446,848975],[857,2297],[-657,304],[-3077,3613],[-399,-1849],[1374,100],[216,-1895],[-2564,1819],[3079,-4609],[1171,220]],[[587585,849699],[-646,1460],[-1172,-1555],[1818,95]],[[576897,854139],[-443,162]],[[576454,854301],[-1548,-1193],[1826,-4688]],[[576732,848420],[152,25]],[[576884,848445],[395,2763],[-382,2931]],[[541480,852979],[-1184,-1323],[-1071,-3768],[344,-681],[1911,5772]],[[545566,856089],[-2918,-151],[1284,-796],[1634,947]],[[538995,854750],[20,1368],[-2508,170],[280,-2440],[-1181,897],[-539,-4145],[1156,1315],[2108,700],[664,2135]],[[574388,886199],[1329,-1605],[1852,1547],[-3181,58]],[[808666,875704],[-89,-3371],[864,42],[70,3529],[-604,2084],[1769,920],[-988,-1704],[1908,-641],[736,1839],[-1092,1421],[-2668,-1308],[-1701,923],[-963,1693],[843,-3241],[1968,-830],[-53,-1356]],[[601163,868433],[-2027,3839],[141,2843],[-3354,2343],[246,-1205],[2030,-1123],[560,-1699],[-1394,-144],[-1142,1644],[-29,-4945],[2298,-1770],[1032,-2898],[1572,2012],[67,1103]],[[575103,870033],[-456,1418],[-1619,-283],[2075,-1135]],[[565308,868591],[687,2172],[1572,1448],[-398,753],[-1313,-1732],[-548,-2641]],[[574860,877079],[-457,-1440],[-2353,-868],[458,2157],[-1308,-1179],[806,-1433],[-2040,-5057],[865,-1794],[573,2204],[-180,2069],[654,1513],[3076,2356],[-94,1472]],[[581993,872761],[1135,1841],[-1847,695],[192,-2281],[-3509,461],[-859,2011],[1692,22],[-591,2050],[-2474,1261],[823,-1749],[-36,-2598],[978,-1509],[2521,-1952],[-1514,-494],[-577,-1386],[-660,1288],[-1517,-2391],[2228,-540],[-349,-980],[2557,1064],[-381,2511],[1023,-550],[1885,1843],[-720,1383]],[[597257,880953],[-1849,2047],[1683,-3970],[166,1923]],[[592892,880587],[-374,-949],[2109,-325],[-1735,1274]],[[583315,879692],[-2504,1663],[836,-2316],[1169,-221],[1433,-1873],[-934,2747]],[[590325,892960],[-1483,2117],[-1322,-558],[2805,-1559]],[[550002,895031],[-92,1328],[-1727,-154],[1819,-1174]],[[580190,897575],[-2219,-1640],[1544,169],[675,1471]],[[755379,910475],[-7289,-911],[1729,-560],[6655,1098],[-1095,373]],[[744889,916455],[-2083,2995],[881,-2910],[1202,-85]],[[780618,942300],[1406,549],[-1870,3023],[3704,-1323],[-412,1551],[8865,1674],[-2125,1828],[-648,-1651],[-2725,-820],[-4020,-206],[-4747,1302],[1105,-1681],[-2744,-1047],[3870,-856],[341,-2343]],[[579019,915436],[-3570,-2388],[840,-960],[2478,660],[252,2688]],[[748524,915745],[1750,953],[4056,-718],[-3761,1228],[-3777,-1505],[1732,42]],[[589764,898704],[-271,1975],[-1256,137],[1527,-2112]],[[584338,890689],[964,-760],[2410,692],[-3374,68]],[[603422,861159],[2045,-467],[-643,1444],[-1402,-977]],[[582879,867448],[1589,-1766],[1945,-4630],[961,-1418],[679,1946],[2225,-351],[1233,2762],[-1159,2814],[-2642,1894],[-2011,2000],[-2820,-3251]],[[779963,809216],[-1131,409],[-518,-5760],[697,440],[952,4911]],[[756434,802727],[791,-1756],[2112,1637],[-1302,2231],[-1826,-1453],[225,-659]],[[692465,805016],[-1413,-1035],[-246,-1466],[1570,-68],[-579,1015],[668,1554]],[[805177,833755],[-802,1378],[-1061,-1398],[-62,-2025],[-3079,-7966],[-1466,-2430],[-1286,-1172],[-1723,-3464],[-1088,-858],[-2485,-3589],[-3818,-1032],[1665,-1374],[1211,-59],[2699,1251],[1080,1513],[354,2173],[4538,2709],[1981,3088],[750,253],[-264,2743],[1133,362],[866,1690],[-183,1343],[1040,6864]],[[292109,640353],[-1063,1606],[183,1013],[880,-2619]],[[752159,635584],[-729,-143],[459,1554],[-494,3009],[472,-131],[544,-1945],[-252,-2344]],[[284284,648382],[233,-3023],[-549,79],[-635,2768],[951,176]],[[856273,662620],[-854,-1163],[-828,-2143],[211,2010],[1460,2654],[11,-1358]],[[285484,658185],[54,3465],[-910,2455],[1298,-2211],[-442,-3709]],[[281964,663072],[1525,90],[-2020,-1408],[495,1318]],[[656077,664210],[-649,-1303],[-1687,-315],[2408,2105],[-72,-487]],[[836135,638730],[-470,-4119],[-718,2555],[-710,1103],[-704,3598],[241,3313],[1308,4559],[1141,3284],[1536,1437],[932,-1787],[-805,-4998],[-450,-4183],[-505,-2710],[-796,-2052]],[[284045,651095],[-4,-1445],[-833,-1043],[-1084,2015],[766,2339],[512,370],[643,-2236]],[[450460,673524],[-463,1570],[712,168],[-249,-1738]],[[188325,676558],[-210,-1398],[-654,463],[122,1897],[742,-962]],[[185678,676836],[-1180,2138],[-19,947],[1070,-1606],[129,-1479]],[[838507,691292],[-1462,897],[355,668],[1107,-1565]],[[634098,680225],[-539,1034],[287,1066],[252,-2100]],[[457220,671475],[32,-1616],[-892,-536],[76,2191],[784,-39]],[[859589,671840],[-799,241],[1458,1587],[-659,-1828]],[[454626,672853],[-899,-2209],[-687,1970],[2184,1117],[-598,-878]],[[460564,671605],[674,3389],[352,-926],[-279,-1965],[-747,-498]],[[577340,717578],[-351,1436],[1432,1552],[-400,-2182],[-681,-806]],[[594456,712459],[-2952,-2899],[-1368,910],[-367,1326],[1099,1290]],[[590868,713086],[636,1300],[1436,-323],[2790,1526],[-1447,-1786],[173,-1344]],[[874812,707856],[-735,35],[935,1481],[-200,-1516]],[[566256,715245],[1279,-1017],[1135,362],[2095,-703],[748,-969],[1175,429],[212,-1009],[-4012,-653],[-253,916],[-2292,930],[-934,1004],[847,710]],[[543268,731152],[-1325,-4513],[541,-2638],[-505,-1929],[-1697,657],[-997,1807],[-659,-18],[-4083,4261],[830,2152],[467,-878],[715,921],[1449,-1123],[2290,265],[944,747],[2030,289]],[[884288,728792],[-272,1022],[715,1904],[198,-1484],[-641,-1442]],[[557256,732117],[414,-1853],[-980,1577],[566,276]],[[572483,731138],[-689,2109],[873,-197],[-184,-1912]],[[300269,747979],[-3587,-2408]],[[296682,745571],[-2327,-92]],[[294355,745479],[1273,1664],[4641,836]],[[526334,758316],[195,-4003],[-1012,-4413],[-1052,1205],[-672,4558],[409,1138],[2132,1515]],[[508736,740452],[844,-171],[-1045,-2725],[-1949,1847],[2188,2117],[-38,-1068]],[[555771,738334],[-1191,1739],[527,556],[664,-2295]],[[565042,735526],[1899,-1704],[491,-2672],[-1436,1073],[-1407,2370],[-1061,411],[1514,522]],[[573361,737722],[483,-1765],[-1176,-33],[-879,1032],[1572,766]],[[570660,741596],[-224,-1038],[-831,1135],[1055,-97]],[[511926,740758],[-1253,797],[1065,332],[188,-1129]],[[526755,746921],[481,-2266],[-523,-6785],[-363,-1273],[-1194,591],[-486,-1933],[-647,81],[-640,1653],[359,3754],[-211,2686],[-782,2127],[96,1550],[965,-374],[1824,2411],[1121,-2222]],[[864373,703794],[1134,295],[355,-889],[-481,-1352],[998,-116],[222,-2433],[-679,-1487],[-1097,-7039],[-1811,-2308],[289,1504],[-373,2662],[-184,-3198],[-1078,671],[337,1834],[-355,2899],[1238,3131],[-717,2804],[-708,73],[133,-1502],[-967,-746],[-685,3027],[222,762],[1957,1597],[331,1186],[1304,221],[615,-1596]],[[452247,699446],[1382,-656],[-1327,-214],[-55,870]],[[850907,701549],[29,1403],[1566,324],[-78,-1031],[-1517,-696]],[[873214,707667],[778,-176],[282,-2406],[-1005,-1257],[-543,-2139],[-1527,1562],[-961,-894],[-858,-3069],[-931,-471],[-408,913],[-229,3044],[-909,-535],[1549,2073],[392,1792],[971,-386],[1245,532],[344,1305],[1027,717],[783,-605]],[[892303,749827],[628,190],[20,-4701],[929,-1896],[502,-2646],[-214,-4344],[-674,-808],[-530,-3381],[-997,-393],[-501,-2300],[301,-2858],[-190,-2756],[-946,-2958],[-16,-2628],[701,-1980],[-1157,-1272],[-114,-1441],[-1378,-2177],[-261,2353],[722,1444],[-699,697],[-552,-3057],[-1074,805],[-454,-2600],[-690,-1303],[-97,2107],[-626,661],[-1078,-2904],[-1794,403],[-1338,-483],[592,1124],[-1084,198],[-91,1503],[-886,-2259],[891,-2099],[-1455,-872],[-1149,-3644],[-1287,-50],[-771,2042],[-209,2313],[791,1256],[-1791,1569],[-1477,-400],[-667,-1010],[-2294,-1332],[-2512,-447],[-255,-2300],[-1127,1264],[-2282,-452],[237,2469],[971,122],[1395,1856],[2964,4650],[1259,-311],[2327,477],[2667,1231],[424,-1313],[981,-132],[1153,1567],[-247,1321],[1922,4452],[404,3792],[1331,829],[-1107,-2074],[257,-1984],[905,-396],[477,1074],[2238,1581],[1570,3706],[1432,1769],[1766,7515],[51,2004],[-679,761],[567,2590],[-254,1680],[914,1246],[372,2494],[671,-204],[143,-1790],[1221,-65],[226,2141],[-1109,-621],[379,2173],[812,-788]],[[877937,714367],[-468,-2295],[903,1565],[-435,730]],[[328330,795571],[-1162,-87]],[[327168,795484],[-3740,1896]],[[323428,797380],[-886,1532]],[[322542,798912],[-1542,1007],[857,675]],[[321857,800594],[3536,-1399],[2892,-2500]],[[328285,796695],[45,-1124]],[[915797,775121],[-60,1171],[1897,2214],[568,-30],[-2405,-3355]],[[323107,780571],[1533,-828]],[[324640,779743],[2683,385]],[[327323,780128],[389,-389],[-2374,-2489]],[[325338,777250],[-485,1590]],[[324853,778840],[-622,-690]],[[324231,778150],[-1339,1448],[-978,163]],[[321914,779761],[-770,1278]],[[321144,781039],[1096,2492]],[[322240,783531],[-262,-1695]],[[321978,781836],[1129,-1265]],[[331391,775898],[539,2552]],[[331930,778450],[1778,-263]],[[333708,778187],[103,-1152]],[[333811,777035],[-1550,-1839]],[[332261,775196],[-2494,-479]],[[329767,774717],[-588,2178],[1737,5066]],[[330916,781961],[1283,1226]],[[332199,783187],[213,-1397],[-681,-3528],[-1340,-2777],[1000,413]],[[906131,768341],[1001,-340],[-1266,-1152],[-1459,-2374],[1724,3866]],[[899511,766086],[2706,-1047],[1512,2332],[-671,-3374],[666,-2736],[1367,494],[-911,-1254],[-2429,-1347],[-1837,-389],[-1500,-2739],[-536,-2481],[-2024,1527],[-1823,1903],[-1236,-191],[-1169,-1213],[-1404,1287],[-459,-1334],[2322,-3135],[-1364,62],[-1420,-2324],[-426,908],[314,1993],[-799,2812],[196,1550],[1653,2374],[-261,1500],[2511,-614],[281,2626],[686,2232],[382,4129],[-553,2604],[985,2095],[2129,-4091],[1623,-2502],[1489,-1657]],[[295648,774097],[-1094,-164]],[[294554,773933],[1345,1560],[-251,-1396]],[[912776,773199],[-1906,-1939],[-710,-78],[-1251,-2510],[-861,-884],[969,2677],[1775,2189],[1984,545]],[[9463,811999],[432,-667]],[[9895,811332],[-1456,-891]],[[8439,810441],[1024,1558]],[[6513,810872],[1645,1335],[-236,-1097],[-1409,-238]],[[13068,812920],[2748,1149]],[[15816,814069],[-104,-820],[-2644,-329]],[[33432,820758],[-3061,-3029]],[[30371,817729],[1969,3695]],[[32340,821424],[1032,595]],[[33372,822019],[60,-1261]],[[134017,819872],[828,-2926]],[[134845,816946],[-375,-732]],[[134470,816214],[1025,-2515],[-2620,3729],[58,1281],[-1119,819]],[[131814,819528],[2203,344]],[[142910,818356],[118,-2495]],[[143028,815861],[-470,-1356],[-187,2806]],[[142371,817311],[-426,-530],[-772,2038]],[[141173,818819],[401,1553],[1336,-2016]],[[34659,820350],[-795,281],[1868,1201]],[[35732,821832],[440,2586]],[[36172,824418],[1857,-1573],[-1281,-1312],[-2089,-1183]],[[139308,819707],[-1857,2230]],[[137451,821937],[1590,-639]],[[139041,821298],[267,-1591]],[[131512,825393],[1448,-552]],[[132960,824841],[1295,634]],[[134255,825475],[-953,-5192]],[[133302,820283],[-2316,1437]],[[130986,821720],[-577,1603]],[[130409,823323],[11,2256]],[[130420,825579],[1092,-186]],[[531559,829920],[1050,-499],[-783,-1059],[-1172,856],[905,702]],[[883167,831111],[738,-350],[-1514,-2253],[-551,1304],[318,1916],[1009,-617]],[[45900,830448],[327,-1453]],[[46227,828995],[-4071,-1875]],[[42156,827120],[-178,1117],[994,1619]],[[42972,829856],[1839,938]],[[44811,830794],[1089,-346]],[[962916,829609],[-14,-859],[-2483,3557],[1457,103],[1040,-2801]],[[541910,830692],[-1121,476],[225,1152],[896,-1628]],[[534913,835213],[-983,-1888],[-980,-4110],[-579,2453],[-1021,105],[-854,3064],[1378,1314],[958,-1456],[130,1602],[1978,569],[-27,-1653]],[[129708,833783],[-959,-1626]],[[128749,832157],[44,1600],[915,26]],[[529569,834175],[390,-2823],[-2213,177],[-356,2087],[2179,559]],[[136169,833460],[-581,-1676]],[[135588,831784],[-721,1199]],[[134867,832983],[-874,-1440],[-232,1485]],[[133761,833028],[614,2461]],[[134375,835489],[988,732]],[[135363,836221],[806,-2761]],[[482975,836075],[-490,-1917],[-389,1335],[879,582]],[[563676,853234],[-1059,-811],[-1350,1503],[1475,815],[934,-1507]],[[128983,838496],[1009,-115]],[[129992,838381],[2860,-4972]],[[132852,833409],[-1162,-96]],[[131690,833313],[1708,-1515],[-438,-2939]],[[132960,828859],[-1800,1990]],[[131160,830849],[-892,1846]],[[130268,832695],[195,1361],[-1797,1157]],[[128666,835213],[317,3283]],[[280734,838063],[-2959,-1979]],[[277775,836084],[2024,3959]],[[279799,840043],[935,-1980]],[[132963,836150],[-1464,799]],[[131499,836949],[779,2491]],[[132278,839440],[868,-1507],[-183,-1783]],[[483950,838526],[-1108,-330],[205,2116],[903,-1786]],[[127916,837243],[-665,-301]],[[127251,836942],[-512,4513]],[[126739,841455],[641,555],[1123,-1671],[-587,-3096]],[[76620,850469],[855,-1179],[-1868,-861]],[[75607,848429],[-1666,423],[1500,1950]],[[75441,850802],[1179,-333]],[[552990,847363],[-730,-768],[261,-1825],[-2115,-2831],[-27,3770],[1113,1623],[1498,31]],[[75283,847292],[1304,11]],[[76587,847303],[590,-1474]],[[77177,845829],[-2940,-2078]],[[74237,843751],[-1341,-2179],[-1352,1686]],[[71544,843258],[-47,-1370]],[[71497,841888],[-1237,2510]],[[70260,844398],[475,1327]],[[70735,845725],[1404,422]],[[72139,846147],[349,1121]],[[72488,847268],[1055,-526],[1011,1426]],[[74554,848168],[729,-876]],[[545912,838208],[-323,1649],[1703,4598],[257,-149],[-1637,-6098]],[[125084,844493],[969,-3752]],[[126053,840741],[-93,-2907]],[[125960,837834],[-614,2625],[-1265,896],[363,1217]],[[124444,842572],[-838,1284],[-863,-1390]],[[122743,842466],[69,1825]],[[122812,844291],[1226,1278],[1046,-1076]],[[129538,842431],[1145,-729]],[[130683,841702],[-719,-2463]],[[129964,839239],[-1084,-3]],[[128880,839236],[-1046,3232]],[[127834,842468],[1704,-37]],[[482931,845403],[1218,-1399],[-676,-1326],[-2254,2354],[1428,1237],[284,-866]],[[125887,849293],[1223,-106],[-74,-1536]],[[127036,847651],[948,-3245]],[[127984,844406],[-1849,-1451],[291,2312],[-1170,4625],[631,-599]],[[123296,848287],[1697,351]],[[124993,848638],[197,-3377]],[[125190,845261],[-1573,1073],[-539,-1435],[-2435,3271],[685,1462],[1486,294],[482,-1639]],[[482781,850488],[-630,-2027],[-1476,-1606],[-153,2835],[2151,1624],[108,-826]],[[954541,851909],[350,2440],[2254,1221],[120,-1988],[-2724,-1673]],[[562826,852016],[1876,-816],[-1563,-1498],[-1507,-452],[-904,2031],[2098,735]],[[891694,943123],[-2524,1095],[2077,531],[447,-1626]],[[814964,945499],[-1680,-1810],[-3553,1529],[1614,1161],[3619,-880]],[[907764,951248],[4547,253],[119,-836],[4524,-315],[1507,-1627],[-2915,-1019],[-4179,314],[-5400,2207],[1797,1023]],[[210778,949266],[-2132,660]],[[208646,949926],[1503,1671]],[[210149,951597],[1956,-1581]],[[212105,950016],[-1327,-750]],[[240159,949216],[-12,-1995]],[[240147,947221],[-3196,-291]],[[236951,946930],[-5173,2064],[2469,3189]],[[234247,952183],[3455,383]],[[237702,952566],[2457,-3350]],[[449998,951464],[1151,-2456],[-3013,53],[-514,1881],[2376,522]],[[183799,965371],[-3325,1261]],[[180474,966632],[1941,652]],[[182415,967284],[1384,-1913]],[[250463,962485],[-3651,709],[-5,1308]],[[246807,964502],[2715,-79]],[[249522,964423],[941,-1938]],[[560022,970354],[4190,-3580],[4959,-1392],[-5833,-2848],[-192,1561],[-4675,-583],[1676,2859],[-3585,3503],[3460,480]],[[193893,964006],[-4872,-1067]],[[189021,962939],[-3367,1103],[-63,2262]],[[185591,966304],[5502,1043],[4304,-54],[-3358,-1451],[1854,-1836]],[[209605,962901],[-1869,-922],[-2364,3218],[4233,-2296]],[[234765,965592],[5282,-126]],[[240047,965466],[176,-1756],[-6854,57]],[[233369,963767],[1396,1825]],[[200410,955317],[-468,-1498]],[[199942,953819],[3136,-133]],[[203078,953686],[1376,1646]],[[204454,955332],[2543,-1863]],[[206997,953469],[-1060,-3283]],[[205937,950186],[-3586,-1567],[-4660,816]],[[197691,949435],[-5861,-2524],[-4385,-1315]],[[187445,945596],[-2762,79]],[[184683,945675],[-1718,1990]],[[182965,947665],[3602,1240],[3235,261]],[[189802,949166],[1770,1228],[-7438,-937]],[[184134,949457],[-847,2167]],[[183287,951624],[-1209,-2052]],[[182078,949572],[-3547,-710]],[[178531,948862],[-5198,1799]],[[173333,950661],[1238,1192]],[[174571,951853],[2992,119]],[[177563,951972],[2654,1261],[-5287,-618]],[[174930,952615],[1909,1655]],[[176839,954270],[-755,1141],[2858,2156]],[[178942,957567],[3852,83]],[[182794,957650],[1029,-1449]],[[183823,956201],[3128,-31],[4569,-3869]],[[191520,952301],[5461,-249],[-1970,2112]],[[195011,954164],[1016,1458]],[[196027,955622],[-2333,1824],[2588,2032]],[[196282,959478],[2421,-133],[-127,-1769]],[[198576,957576],[1834,-2259]],[[889023,953962],[2131,-1174],[1859,3000],[2709,-1385],[3404,-235],[4359,-1648],[-1033,-1876],[-2398,-1329],[-3584,1736],[758,1909],[-2545,5],[1474,-3125],[1420,-965],[-1820,-888],[-1349,1012],[-4776,-855],[-1839,585],[-1407,-1713],[-2797,835],[-2429,1933],[141,3707],[4586,2650],[3136,-2179]],[[877634,951478],[-1379,-120],[685,2700],[694,-2580]],[[238068,960381],[3611,-1730]],[[241679,958651],[4696,358],[5273,-2913]],[[251648,956096],[-5202,-173]],[[246446,955923],[5762,-2357],[-1225,-1167],[2026,-659]],[[253009,951740],[4609,971],[3302,-685]],[[260920,952026],[5935,1877]],[[266855,953903],[4940,71]],[[271795,953974],[5088,-1196]],[[276883,952778],[1910,-2546]],[[278793,950232],[-2009,-875],[2656,-794]],[[279440,948563],[-2434,-1991]],[[277006,946572],[-4253,-622]],[[272753,945950],[-9035,772]],[[263718,946722],[-425,-523],[-8913,-145]],[[254380,946054],[231,1722]],[[254611,947776],[-4179,-1399],[-5881,1449]],[[244551,947826],[-1293,3277]],[[243258,951103],[995,1846],[-2842,4124],[-6062,-532]],[[235349,956541],[-4365,2738]],[[230984,959279],[243,1454]],[[231227,960733],[3111,545]],[[234338,961278],[3730,-897]],[[168348,952708],[4562,2934],[350,-868],[-2743,-2669],[-2169,603]],[[228608,957739],[1014,-6202]],[[229622,951537],[-940,-1733]],[[228682,949804],[-2484,-698]],[[226198,949106],[-4627,-9]],[[221571,949097],[-1380,2007]],[[220191,951104],[3167,1830]],[[223358,952934],[-8195,-840]],[[215163,952094],[1662,2193]],[[216825,954287],[235,3289]],[[217060,957576],[5106,-2959]],[[222166,954617],[-2419,3175]],[[219747,957792],[6056,1294]],[[225803,959086],[2805,-1347]],[[216034,955063],[-3020,-1484],[-2878,2477]],[[210136,956056],[4908,588]],[[215044,956644],[990,-1581]],[[211047,958429],[2699,-788]],[[213746,957641],[-3387,-733]],[[210359,956908],[688,1521]],[[448381,955226],[-1397,2299],[981,1254],[416,-3553]],[[179024,963052],[-2040,-1550]],[[176984,961502],[181,-2906],[-2593,-1855]],[[174572,956741],[-2406,880]],[[172166,957621],[666,2001]],[[172832,959622],[-2809,-1607]],[[170023,958015],[-1046,-2290],[-989,1266],[-1080,-2852]],[[166908,954139],[-3074,957]],[[163834,955096],[-3836,-451]],[[159998,954645],[97,2707],[2089,239],[7010,5116]],[[169194,962707],[5031,50],[2544,1359]],[[176769,964116],[2255,-1064]],[[546629,978121],[3249,-1200],[3148,-3242],[2981,-67],[3406,-2402],[-4500,-696],[-3693,-3541],[-4830,-8566],[-6149,3672],[-1155,2163],[8228,1383],[-501,675],[-6247,-859],[-2565,1550],[8601,1910],[16,1855],[-3792,-1128],[-263,1824],[-2377,-626],[475,-1545],[-4133,-1051],[-3823,2839],[1391,1114],[-1930,2245],[-1033,-911],[-950,3951],[6037,-660],[2550,-2047],[1767,2719],[4725,-4843],[-1329,4150],[2696,1334]],[[306975,996546],[8048,-431]],[[315023,996115],[-2237,-1635]],[[312786,994480],[6922,1379],[5055,-1988]],[[324763,993871],[4467,-581]],[[329230,993290],[-1943,-2511]],[[327287,990779],[-6660,-1834],[-5699,-695]],[[314928,988250],[-4700,-2105]],[[310228,986145],[7173,1381]],[[317401,987526],[699,-1242]],[[318100,986284],[-8741,-3591]],[[309359,982693],[-7659,-5431],[-5790,-32],[17,-1548]],[[295927,975682],[-4981,-439]],[[290946,975243],[-4554,541]],[[286392,975784],[6573,-2724],[-11248,133]],[[281717,973193],[1942,-786]],[[283659,972407],[4519,382]],[[288178,972789],[5063,-1675]],[[293241,971114],[-1237,-1062]],[[292004,970052],[-4153,-140],[3278,-1146]],[[291129,968766],[-1868,-1883],[-5963,-378]],[[283298,966505],[-177,-2530]],[[283121,963975],[-2203,-1105]],[[280918,962870],[-6244,-373]],[[274674,962497],[4934,-659]],[[279608,961838],[334,-1317]],[[279942,960521],[2589,247]],[[282531,960768],[12,-2408],[-6683,-2339]],[[275860,956021],[-1334,1992]],[[274526,958013],[-3776,1247],[824,-1525]],[[271574,957735],[-4590,-75]],[[266984,957660],[-3488,-880]],[[263496,956780],[-2707,772]],[[260789,957552],[-6334,-177]],[[254455,957375],[-3261,515]],[[251194,957890],[195,1984],[3060,1641]],[[254449,961515],[4295,418],[-3453,3228]],[[255291,965161],[2992,1025]],[[258283,966186],[3971,-2555]],[[262254,963631],[2361,-592]],[[264615,963039],[2664,1016]],[[267279,964055],[-4194,157]],[[263085,964212],[-717,2184]],[[262368,966396],[1453,2279]],[[263821,968675],[-3315,-1370]],[[260506,967305],[827,1550]],[[261333,968855],[-4533,-984],[2067,3540]],[[258867,971411],[5011,818]],[[263878,972229],[4812,-841]],[[268690,971388],[2313,790]],[[271003,972178],[-3158,889],[-4206,3309],[-3697,1380]],[[259942,977756],[316,2809]],[[260258,980565],[7176,-535],[5189,-3001]],[[272623,977029],[2349,-174],[-5420,3465]],[[269552,980320],[8084,1485]],[[277636,981805],[8891,2071]],[[286527,983876],[-4724,256],[733,1459],[-7556,-3038]],[[274980,982553],[-8525,-584],[-9038,672]],[[257417,982641],[-4421,805]],[[252996,983446],[1412,1150]],[[254408,984596],[-4262,1025],[4079,2322]],[[254225,987943],[-8122,80]],[[246103,988023],[2535,1771]],[[248638,989794],[7920,1232]],[[256558,991026],[7206,-606]],[[263764,990420],[-4267,1210],[4678,1554]],[[264175,993184],[15200,-3524]],[[279375,989660],[-8407,3393]],[[270968,993053],[4004,2085],[6281,-591]],[[281253,994547],[-3906,1373]],[[277347,995920],[20398,1008],[9230,-382]],[[38512,862457],[1128,-411],[383,-2376]],[[40023,859670],[-1431,-816],[-2868,1381]],[[35724,860235],[-825,1173]],[[34899,861408],[1666,62]],[[36565,861470],[1947,987]],[[89622,859078],[1542,3229]],[[91164,862307],[539,-616]],[[91703,861691],[-2081,-2613]],[[319909,868276],[-1665,1681]],[[318244,869957],[1784,75],[-119,-1756]],[[291239,908966],[74,-4127]],[[291313,904839],[-1814,-1504],[-3402,-98]],[[286097,903237],[-836,2602],[1571,3111]],[[286832,908950],[2427,625],[1980,-609]],[[295495,906299],[-2644,266],[-369,1412],[3531,-575]],[[296013,907402],[-518,-1103]],[[639625,914604],[-1775,-1932],[-2665,-750],[-1078,1820],[979,2346],[1650,444],[2889,-1928]],[[543778,910905],[1580,1867],[528,-1442],[-1685,-1444],[-4597,-1176],[3208,2518],[196,2533],[1138,1390],[-368,-4246]],[[542241,913167],[42,-1936],[-1948,99],[1906,1837]],[[279970,912589],[-542,459]],[[279428,913048],[2248,2652]],[[281676,915700],[1021,-396]],[[282697,915304],[-2727,-2715]],[[259456,906015],[-1011,2159]],[[258445,908174],[1496,493]],[[259941,908667],[-485,-2652]],[[291997,909646],[-1442,1047],[1157,724]],[[291712,911417],[285,-1771]],[[304619,875284],[-1192,285]],[[303427,875569],[-1024,1666]],[[302403,877235],[1923,-856]],[[304326,876379],[293,-1095]],[[23714,881749],[2868,349]],[[26582,882098],[2786,-2077],[1975,-223]],[[31343,879798],[-377,-826]],[[30966,878972],[-2572,-459]],[[28394,878513],[-2080,1691]],[[26314,880204],[-3512,269]],[[22802,880473],[912,1276]],[[284615,879658],[-1122,-1024]],[[283493,878634],[-1569,1996]],[[281924,880630],[2232,-120],[459,-852]],[[285098,881443],[641,554],[1267,-1706],[-1908,1152]],[[264112,891353],[853,1104],[7118,-4758]],[[272083,887699],[1039,-2557],[-630,-1075]],[[272492,884067],[2983,348]],[[275475,884415],[1463,-1942]],[[276938,882473],[-2067,-1781]],[[274871,880692],[-3699,1453]],[[271172,882145],[-248,1304]],[[270924,883449],[-2853,1020]],[[268071,884469],[-650,-1693]],[[267421,882776],[-2513,-2986]],[[264908,879790],[-2396,-1008]],[[262512,878782],[-860,3361],[-2894,-777]],[[258758,881366],[-950,574]],[[257808,881940],[2603,2753],[-340,2541]],[[260071,887234],[1146,6745]],[[261217,893979],[1131,1269]],[[262348,895248],[1764,-3895]],[[456824,897085],[1909,906],[-723,-1653],[769,-1905],[3246,-1371],[250,-2408],[-2684,-4130],[-4204,-1983],[-1611,-1456],[-3266,-903],[-2327,-1815],[-4289,883],[-2913,2249],[-3778,-581],[-379,1079],[1907,308],[1139,1934],[-2279,2353],[-4331,405],[5928,1098],[-1615,1054],[1748,1308],[-2939,788],[-2771,-1024],[-1542,550],[953,1536],[2296,-57],[-1255,1892],[1995,-517],[1593,521],[-1873,1701],[1927,432],[2831,-2396],[-70,-3268],[840,-1229],[1083,2319],[792,-516],[272,2739],[2485,-1546],[220,1797],[1681,552],[1885,-2007],[-550,1940],[2074,-1144],[1103,1413],[1125,-422],[577,1867],[1543,402],[1228,-1695]],[[481580,873383],[-254,-1407],[-1249,1749],[1503,-342]],[[279041,874472],[614,-2284]],[[279655,872188],[-957,-2261],[-1657,1029]],[[277041,870956],[677,3109]],[[277718,874065],[1323,407]],[[272221,877686],[-315,-1789]],[[271906,875897],[-2506,-2620]],[[269400,873277],[-1897,-294]],[[267503,872983],[-173,848]],[[267330,873831],[-420,723]],[[266910,874554],[36,302]],[[266946,874856],[1453,2538]],[[268399,877394],[3822,292]],[[0,890205],[0,1449]],[[0,891654],[0,21752],[3127,-1359],[505,-1232],[9297,-5142],[1377,-1952],[-210,-4298],[1476,-1654],[1079,2575],[-1228,1921],[2133,247],[1253,-1043],[3980,-218],[4310,-4517],[1295,-155],[-2118,-1646],[-349,-1445],[-2180,1024],[1101,-1448],[-2368,-319],[-2434,1096],[1587,-1516],[-3,-2233],[-2184,-1017],[-28,-3431],[-2755,898],[-880,1118],[-2993,976],[-1273,1235],[-666,2726],[-2674,845],[-3483,-763],[-2214,5101],[-1880,-1943],[1199,-2969],[-1799,-2663]],[[264792,893213],[-1282,1457]],[[263510,894670],[549,1112]],[[264059,895782],[733,-2569]],[[267428,894527],[-1090,-148]],[[266338,894379],[-803,2127],[1893,-1979]],[[259474,925417],[4151,836]],[[263625,926253],[624,-889]],[[264249,925364],[584,3462]],[[264833,928826],[-3477,2372]],[[261356,931198],[1405,1352],[2929,-960],[-2749,2184]],[[262941,933774],[-843,2092],[1739,3324],[3435,482]],[[267272,939672],[3118,1852],[3482,-563],[3142,-5266],[-2651,-2571]],[[274363,933124],[1030,-2371],[2268,2860]],[[277661,933613],[3730,-253],[2703,-1016]],[[284094,932344],[-1734,2489],[4047,714]],[[286407,935547],[4442,-1421]],[[290849,934126],[1086,-2253]],[[291935,931873],[1695,-297]],[[293630,931576],[-308,-2239]],[[293322,929337],[4172,32],[4572,-1873]],[[302066,927496],[-664,-1520],[1952,-12]],[[303354,925964],[381,-1376]],[[303735,924588],[-1724,-2195]],[[302011,922393],[3684,2042]],[[305695,924435],[4421,-1908]],[[310116,922527],[-1557,-1872]],[[308559,920655],[485,-1573],[1732,2211]],[[310776,921293],[2102,-1660]],[[312878,919633],[395,-1800]],[[313273,917833],[-2479,139]],[[310794,917972],[-1108,-1048]],[[309686,916924],[4839,-1425]],[[314525,915499],[-89,-1090]],[[314436,914409],[-3154,565]],[[311282,914974],[518,-1241]],[[311800,913733],[-795,-2890]],[[311005,910843],[3678,-624]],[[314683,910219],[-325,-1362]],[[314358,908857],[1718,384],[-559,-2228],[3991,824]],[[319508,907837],[2280,-2491]],[[321788,905346],[889,-2126]],[[322677,903220],[2211,-172],[-1837,-2445]],[[323051,900603],[4814,1165],[1858,-2195]],[[329723,899573],[-1564,-1989]],[[328159,897584],[-1918,557]],[[326241,898141],[1560,-2201]],[[327801,895940],[-1663,-5]],[[326138,895935],[-191,-2337]],[[325947,893598],[-1885,-138],[-747,-4080]],[[323315,889380],[-803,1074]],[[322512,890454],[-1832,43]],[[320680,890497],[-2351,3836]],[[318329,894333],[1103,1858]],[[319432,896191],[-2282,-478]],[[317150,895713],[-2671,3310]],[[314479,899023],[-1444,83],[-11,-1576],[-1548,1105]],[[311476,898635],[1904,-2700]],[[313380,895935],[-2983,-568],[3164,-2952],[-578,-496]],[[312983,891919],[1831,-2268],[2023,-521]],[[316837,889130],[2400,-2661]],[[319237,886469],[-265,-2421]],[[318972,884048],[1365,0]],[[320337,884048],[456,-4527],[-1882,2964]],[[318911,882485],[396,-3137]],[[319307,879348],[1016,-1969]],[[320323,877379],[-1331,179]],[[318992,877558],[313,-1697]],[[319305,875861],[-3261,2732],[-529,-474]],[[315515,878119],[-2126,1645]],[[313389,879764],[-1982,2540]],[[311407,882304],[474,-1842]],[[311881,880462],[-2142,1793],[-1159,-131]],[[308580,882124],[2138,-3146],[1293,-466]],[[312011,878512],[4710,-5241]],[[316721,873271],[-768,-2018]],[[315953,871253],[-3287,1676]],[[312666,872929],[-2607,497],[-1955,1008]],[[308104,874434],[-1285,2010],[-1920,112]],[[304899,876556],[-2826,1654],[-1998,2293],[1436,489],[-2131,1165]],[[299380,882157],[-3390,4234]],[[295990,886391],[-4091,2183]],[[291899,888574],[767,-1392],[-6156,-1892],[-2597,767]],[[283913,886057],[-1065,1485]],[[282848,887542],[219,1905]],[[283067,889447],[2041,1524]],[[285108,890971],[95,1520]],[[285203,892491],[4162,-1339],[333,524]],[[289698,891676],[5994,1005]],[[295692,892681],[-543,1668],[-1910,2206],[3424,3318],[2726,3289]],[[299389,903162],[-3020,6597]],[[296369,909759],[-937,-434]],[[295432,909325],[-894,1684],[-1267,6],[-1375,2388],[-4128,-1721]],[[287768,911682],[-427,1878]],[[287341,913560],[1676,127]],[[289017,913687],[463,1704]],[[289480,915391],[-1725,727]],[[287755,916118],[-292,1438]],[[287463,917556],[-2996,958]],[[284467,918514],[-697,2378]],[[283770,920892],[-1223,-106]],[[282547,920786],[-2176,2219],[-781,-1370],[1272,-2340],[-2018,-490]],[[278844,918805],[-5399,1283]],[[273445,920088],[-1608,-1600]],[[271837,918488],[-2646,964]],[[269191,919452],[-2133,-244]],[[267058,919208],[-6842,1081]],[[260216,920289],[-839,1516]],[[259377,921805],[-3546,-884]],[[255831,920921],[-2632,1606]],[[253199,922527],[-1688,3192]],[[251511,925719],[4475,-695]],[[255986,925024],[1957,398]],[[257943,925422],[-2033,1167],[-3353,470]],[[252557,927059],[-2129,1211]],[[250428,928270],[-498,2703]],[[249930,930973],[453,2745]],[[250383,933718],[1662,3893],[4288,3874]],[[256333,941485],[2641,658]],[[258974,942143],[4608,-153]],[[263582,941990],[377,-672],[-3088,-2574],[-1508,-2308]],[[259363,936436],[1647,-6515]],[[261010,929921],[2814,-2475]],[[263824,927446],[-4350,-2029]],[[301559,899263],[867,-1573],[-276,-2234],[1790,1987],[2847,-459],[185,2188],[-2702,1022],[-1976,1627],[-735,-2558]],[[303757,887946],[-344,2276],[-2683,2000],[626,-2975],[-1076,-1051],[1111,-705],[1617,1086],[749,-631]],[[496366,863369],[710,-551],[-621,-1955],[-1077,995],[988,1511]],[[310717,863514],[897,-667]],[[311614,862847],[-1518,-1158],[621,1825]],[[996837,924325],[82,2397],[3080,1817],[0,-3227],[-3162,-987]],[[0,925312],[0,1133]],[[0,926445],[0,1276]],[[0,927721],[0,818]],[[0,928539],[4572,-51],[2283,-1576],[-804,-1158],[-4681,-855],[-1370,413]],[[970001,916943],[-3922,1519],[1581,1060],[2825,-789],[-484,-1790]],[[565112,924262],[-3005,-1783],[-1012,843],[4017,940]],[[429354,924887],[84,-1591],[-2421,-1176],[-919,587],[-3593,-589],[525,2626],[2036,-204],[3214,1072],[1074,-725]],[[948519,912918],[-918,1240],[595,1534],[323,-2774]],[[232499,915545],[1985,-3018]],[[234484,912527],[-2266,-2158]],[[232218,910369],[-2974,431]],[[229244,910800],[-2357,1773],[-3112,444]],[[223775,913017],[-41,1265],[2777,1204]],[[226511,915486],[522,2488],[1326,635]],[[228359,918609],[4140,-3064]],[[286124,914356],[-898,1615]],[[285226,915971],[1805,-297],[-907,-1318]],[[548619,917037],[1392,-541],[-149,-1818],[-2193,-1020],[-442,1990],[1392,1389]],[[277839,916524],[-2223,991]],[[275616,917515],[3289,791],[-1066,-1782]],[[358295,916778],[-935,1790],[1865,-37],[-930,-1753]],[[353524,919101],[1905,-814],[-187,-1885],[-4071,-1377],[-685,1680],[-3040,1027],[1658,1353],[-1370,1466],[1233,757],[2768,-568],[1789,-1639]],[[553486,919822],[936,-570],[-2243,-2318],[-1209,1120],[2398,2841],[118,-1073]],[[667917,919042],[-28,-1237],[-4132,989],[-1333,2215],[1479,1176],[4014,-3143]],[[475129,924399],[1694,1784],[948,-586],[-2642,-1198]],[[647614,926786],[-329,-1618],[-2148,1873],[2477,-255]],[[720837,935555],[-2348,1009],[1411,1197],[937,-2206]],[[715645,933003],[-2018,38],[1869,1974],[2184,-880],[-2035,-1132]],[[347175,935965],[-1408,-1320],[-1920,892],[3328,428]],[[223930,942411],[4686,1360],[-1247,-1303],[-3439,-57]],[[182061,934716],[2661,676]],[[184722,935392],[811,1698]],[[185533,937090],[5384,-1584]],[[190917,935506],[-1737,-2119]],[[189180,933387],[2097,55],[2596,1753]],[[193873,935195],[-1264,2056],[1812,-146],[3639,-2869],[1355,-4433]],[[199415,929803],[2513,851],[-1083,1508]],[[200845,932162],[-1507,5667]],[[199338,937829],[581,1439]],[[199919,939268],[2995,-431],[3162,-1572]],[[206076,937265],[4064,-9341]],[[210140,927924],[-611,-1954]],[[209529,925970],[1711,-2023]],[[211240,923947],[2511,-637],[4131,-3081],[1444,-144]],[[219326,920085],[298,-2344],[-3608,753]],[[216016,918494],[-1075,-1723]],[[214941,916771],[-2050,794]],[[212891,917565],[747,-2805]],[[213638,914760],[1787,1566],[1309,-410],[329,-2270],[-2883,-1187]],[[214180,912459],[-6141,574]],[[208039,913033],[240,953]],[[208279,913986],[-3115,478]],[[205164,914464],[-1439,1645],[-2169,-2592]],[[201556,913517],[-4184,-1436],[-6569,-1290]],[[190803,910791],[-5047,-284]],[[185756,910507],[-1359,2040]],[[184397,912547],[-214,2113]],[[184183,914660],[-4069,413]],[[180114,915073],[-3763,947]],[[176351,916020],[-1640,2248],[-87,1754]],[[174624,920022],[7064,1258],[5428,-517]],[[187116,920763],[2793,495]],[[189909,921258],[-5902,2263]],[[184007,923521],[-6204,-619]],[[177803,922902],[-4434,256]],[[173369,923158],[-1880,1534],[1250,1600],[5340,1323]],[[178079,927615],[846,975]],[[178925,928590],[-5934,-923]],[[172991,927667],[-54,1592],[-3127,163]],[[169810,929422],[-213,1770]],[[169597,931192],[2032,1642]],[[171629,932834],[-448,1607]],[[171181,934441],[2286,1760]],[[173467,936201],[8093,3209]],[[181560,939410],[1318,-609]],[[182878,938801],[152,-2422]],[[183030,936379],[-969,-1663]],[[167398,943794],[1680,-502]],[[169078,943292],[1633,1284]],[[170711,944576],[2859,-77]],[[173570,944499],[5567,-3631]],[[179137,940868],[177,-1066]],[[179314,939802],[-9763,-4471]],[[169551,935331],[-1239,-1918],[-2145,-876],[-1221,-4188]],[[164946,928349],[-3139,-361]],[[161807,927988],[-3299,-2114],[-2974,3493]],[[155534,929367],[-4875,2725]],[[150659,932092],[2154,2668]],[[152813,934760],[419,2893]],[[153232,937653],[2887,4099]],[[156119,941752],[-2498,3437]],[[153621,945189],[9391,1077]],[[163012,946266],[4869,-1760]],[[167881,944506],[-483,-712]],[[222216,942806],[2345,-1271]],[[224561,941535],[4378,925]],[[228939,942460],[1612,-1309],[-753,-1657]],[[229798,939494],[-3234,-2290]],[[226564,937204],[2670,-48]],[[229234,937156],[880,-2070]],[[230114,935086],[1713,331]],[[231827,935417],[-705,-2280]],[[231122,933137],[507,-2844]],[[231629,930293],[-2691,-1209]],[[228938,929084],[-2435,850]],[[226503,929934],[723,-1969]],[[227226,927965],[-2690,-436],[-3965,4650]],[[220571,932179],[-3138,964],[-2749,2773]],[[214684,935916],[2198,1623]],[[216882,937539],[1588,-1840]],[[218470,935699],[2405,158]],[[220875,935857],[1078,1127]],[[221953,936984],[-951,1726]],[[221002,938710],[-2810,1045]],[[218192,939755],[1488,2218]],[[219680,941973],[2536,833]],[[416796,999793],[11551,-1800],[-17216,-1041],[13863,-107],[5218,547],[1814,-1672],[8193,-1670],[-6503,-1827],[-15882,-746],[-643,-1219],[12951,271],[2377,-1778],[3304,1841],[4904,337],[532,-2213],[-5350,-4552],[3170,731],[5321,3046],[7111,-987],[5278,2582],[9341,-1093],[1845,-1333],[-8263,-3915],[-6270,-1125],[2287,-863],[-2586,-1359],[-7113,351],[-1971,-2692],[2375,-711],[580,-3145],[-5225,-3538],[-2199,-4529],[2458,718],[3811,-1143],[-3306,-592],[1247,-1484],[5257,-908],[-475,-2589],[-6755,644],[-2598,-1858],[1279,-1832],[3648,-268],[1651,-2733],[232,-3126],[-2942,500],[-3608,-2031],[2378,485],[879,-1926],[1739,1463],[2112,-2937],[-401,-1158],[-4890,-1025],[-3346,1038],[-3,-1320],[5467,-1275],[-396,-2105],[-4654,-1321],[-2230,452],[-3249,2478],[-1760,-1500],[-5450,-2312],[6017,1789],[1912,-146],[5174,-2843],[-714,-4733],[-4933,2246],[-1558,3193],[-5632,-1907],[5240,906],[290,-2555],[7519,-4104],[-1509,-1447],[2086,-132],[637,-5640],[-3244,-526],[-3059,698],[-2140,3960],[-3676,2064],[-3393,-233],[3748,-549],[43,-1519],[-2710,-1382],[-4404,337],[649,-1826],[-1310,-1317],[5647,-474],[-2924,-1613],[5642,1355],[6546,-1414],[2469,67],[-2028,-1901],[-6039,-3225],[267,-565],[-3469,-2743],[-8078,-2390],[-1720,76],[-2062,-1148],[-2246,63],[-2522,1829],[453,-2643],[-2757,-2160],[-2624,-5335],[-3019,-2818],[-1886,1131],[658,-1785],[-2081,-1831],[-1898,240],[-1920,-1649],[-686,691],[1972,3639],[-1281,-370],[-2458,-3774],[-4318,-605],[1705,-1076],[-1877,-1730],[-2308,309],[2506,-3679],[-1665,-1529],[642,-2942],[-1384,-1252],[-163,-1422],[-2641,-3435],[-2454,155],[2191,-899],[-468,-2463],[587,-1751],[-593,-1039],[-1093,-5417],[-1607,-1911],[480,-2273],[-2762,-1358],[-934,1081],[-2572,1116],[-3,1434],[-1851,1012],[548,3349],[-2848,-2160],[-3696,234],[-1991,2497],[-1273,3631],[1532,1122],[-2208,-480],[195,1387],[-2127,1425],[-197,2066],[-2996,4860],[-664,3334],[1323,2105],[3083,849],[-1667,556],[-1887,-1033],[-1449,-2396],[-474,1168],[-708,6194],[-2363,785],[257,2271],[-794,421],[3711,2719],[2153,2273],[-4558,-3851],[-1924,-512],[-56,1536],[1657,2447],[-2350,1829],[238,1675],[3145,1964],[4125,-671],[591,1008],[-3823,180],[-3862,-1706],[1621,3903],[4318,-907],[1083,1603],[-3253,-633],[-2791,468],[955,1857],[2046,535],[1450,-906],[1476,1135],[1470,5839],[1190,1712],[-5452,264],[-2135,1439],[-2753,710],[-1435,1645],[1014,716],[3789,-413],[3548,-1843],[411,4026],[-4531,361],[717,1905],[-1918,459],[-104,1606],[-1349,-2317],[-3815,-190],[-920,1184],[978,2866],[-795,2032],[2254,1115],[146,1368],[-2652,1424],[1098,885],[-2241,1752],[485,1999],[-2158,1918],[1252,1823],[-6522,5086],[243,1799],[-7941,2908],[-5733,946],[-4092,-989],[-5953,-123],[1056,-1033],[-4094,531],[-3710,1967],[3502,1650],[-6161,769],[-1171,2180],[5235,118],[1074,872],[6118,-369],[-839,2375],[-7389,-1268],[-7538,2783],[-2092,1526],[1174,1835],[9450,2092],[4198,1538],[4207,92],[1522,1232],[2431,4719],[-3516,-668],[-3697,843],[404,1461],[8623,3812],[2314,-1012],[431,1970],[4076,-501],[557,3324],[5483,1849],[12599,2042],[3178,-1718],[2240,1542],[5225,-2527],[3759,136],[-4024,3211],[5913,-324],[9917,-3416],[2107,119],[816,3076],[-3649,2116],[11439,316],[-9982,634],[-1074,781],[6681,1464],[4748,-970],[2620,1371],[5774,-1975],[1251,2883],[21874,470]],[[653666,939029],[3082,-635],[-960,-2440],[-2022,-1922],[-162,-3137],[2071,-3494],[2366,-2480],[2029,-1174],[-1332,-828],[-7069,539],[-3382,1145],[2145,1494],[-2200,2464],[-4309,-297],[-1038,1691],[399,1744],[1860,347],[3105,4663],[-478,1367],[4461,1423],[1434,-470]],[[202996,940045],[854,1278]],[[203850,941323],[3059,416],[2400,-897]],[[209309,940842],[183,-1543]],[[209492,939299],[-2102,-2601],[-4394,3347]],[[894957,942510],[3218,-1938],[410,-1911],[-5262,383],[-3378,1020],[1956,2267],[3056,179]],[[241192,944080],[2634,-1117]],[[243826,942963],[3152,218],[2037,-834]],[[249015,942347],[-4898,-6603],[-5815,18]],[[238302,935762],[1822,-1989]],[[240124,933773],[-1340,-2325]],[[238784,931448],[-3209,-8]],[[235575,931440],[-985,4468]],[[234590,935908],[-237,5414]],[[234353,941322],[2598,-189]],[[236951,941133],[-951,2135]],[[236000,943268],[5192,812]],[[279063,941080],[3474,67]],[[282537,941147],[3000,-985]],[[285537,940162],[2547,-2480]],[[288084,937682],[-308,-1542],[-3987,451]],[[283789,936591],[-4624,-835]],[[279165,935756],[-1897,2777]],[[277268,938533],[-1780,924]],[[275488,939457],[171,2234],[3404,-611]],[[696316,937765],[-2094,-63],[637,2135],[2197,413],[1905,-2017],[-2645,-468]],[[688236,956383],[-4119,-1504],[-13684,-3964],[-2374,-2429],[-3890,-2353],[-1573,-51],[-259,-2192],[-4105,-4516],[-1482,-411],[-3954,928],[-1964,-611],[-1491,2461],[2444,1146],[2609,3958],[2546,1951],[1636,2529],[1442,-252],[3540,3042],[5725,1283],[721,1248],[4916,-268],[8032,2230],[4644,2338],[2641,-439],[1151,-2138],[-3152,-1986]],[[933113,802729],[-1883,-1229],[-69,1204],[1283,609],[1157,2200],[-488,-2784]],[[146674,804734],[4765,-1918],[2331,-5262],[1797,-1212]],[[155567,796342],[1386,-3803],[-272,-1472]],[[156681,791067],[-3041,1562],[-1198,969]],[[152442,793598],[753,1585]],[[153195,795183],[-1696,-517]],[[151499,794666],[-511,1450]],[[150988,796116],[-1665,1522],[-289,1292]],[[149034,798930],[-2506,2827]],[[146528,801757],[-1706,-61]],[[144822,801696],[-116,1881]],[[144706,803577],[1165,-240],[57,1057]],[[145928,804394],[-1647,-501]],[[144281,803893],[-798,1456]],[[143483,805349],[1189,689]],[[144672,806038],[2002,-1304]],[[345948,810042],[-1590,-1232],[443,-2495],[-2285,-5023]],[[342516,801292],[-356,-2642]],[[342160,798650],[1598,2823]],[[343758,801473],[238,-888]],[[343996,800585],[1754,338]],[[345750,800923],[-1417,-1733]],[[344333,799190],[-131,-1497],[2445,178]],[[346647,797871],[-104,-1672]],[[346543,796199],[2153,1955],[16,-1114],[1405,593],[940,-712]],[[351057,796921],[128,-1069]],[[351185,795852],[-1091,-2574],[744,-160]],[[350838,793118],[-1027,-1546]],[[349811,791572],[2807,1423]],[[352618,792995],[-218,-1523],[-1316,-1152]],[[351084,790320],[-700,-2418]],[[350384,787902],[716,-812]],[[351100,787090],[892,1988],[1158,682]],[[353150,789760],[-860,-2725]],[[352290,787035],[147,-1173],[945,1863],[357,-1304]],[[353739,786421],[-1155,-5143],[-1519,-7]],[[351065,781271],[-55,2711]],[[351010,783982],[-1493,-1524]],[[349517,782458],[846,3001]],[[350363,785459],[-896,2801]],[[349467,788260],[-1030,-2871]],[[348437,785389],[-817,58]],[[347620,785447],[-1275,-2839]],[[346345,782608],[-1470,-189],[18,1172],[964,527]],[[345857,784118],[1727,2431],[-1380,533],[-190,-946],[-1187,171]],[[344827,786307],[12,1712]],[[344839,788019],[-1010,-875]],[[343829,787144],[-2031,-574]],[[341798,786570],[-3835,606]],[[337963,787176],[-2177,-629]],[[335786,786547],[-431,2517]],[[335355,789064],[2616,3120]],[[337971,792184],[-1073,450],[869,2880]],[[337767,795514],[1177,861]],[[338944,796375],[-99,1854],[1994,6850],[1521,3414]],[[342360,808493],[2013,1738],[1575,-189]],[[341569,796584],[-2717,-3564],[704,54],[2013,3510]],[[896558,826971],[596,-1498],[-161,-2055],[849,-2951],[278,-4044],[-467,-3138],[834,-3629],[1001,-7042],[1267,-5754],[969,-2942],[-1376,2332],[-1092,614],[-1743,-671],[-1474,-6675],[-49,-1980],[1246,-3053],[590,-2534],[744,-254],[264,-2318],[-414,-1968],[-414,3143],[-1957,840],[-1391,-4644],[-686,3163],[579,4083],[-207,2651],[603,2523],[-875,4365],[766,4852],[-197,5604],[377,4192],[-1346,3044],[-171,3179],[396,1674],[56,4643],[1952,641],[500,2656],[-1032,2280],[1185,671]],[[980032,818789],[1734,-952],[-1392,-593],[-1224,1101],[882,444]],[[487744,825735],[-1037,-665],[769,1798],[268,-1133]],[[275745,817216],[-3632,1793]],[[272113,819009],[533,807]],[[272646,819816],[1977,116]],[[274623,819932],[1122,-2716]],[[488342,820617],[-491,-1109],[-540,1495],[1031,-386]],[[538081,826905],[-958,-811],[-522,1768],[684,919],[796,-1876]],[[482727,825162],[530,-6881],[-827,-4031],[-3340,-876],[-381,-705],[-3192,-2340],[-2838,-601],[710,1220],[-1503,-526],[1450,1622],[-1348,-612],[-819,579],[1685,2259],[-401,1894],[1116,1253],[599,1874],[-2271,2671],[724,3240],[-556,963],[4197,-98],[1145,2367],[-1752,239],[1359,2756],[2065,281],[868,-603]],[[479947,831107],[3027,743],[1519,-3282],[-68,-2316],[-1698,-1090]],[[491362,851389],[-286,-1151],[-2160,-2146],[-401,-2258],[2032,772],[3691,-35],[823,-1236],[-2263,-5523],[-1728,-1051],[1561,-389],[-1972,-1722],[2120,-2],[1256,-736],[1366,-1971],[1010,-4719],[3354,-3886],[-337,-570],[1560,-5105],[-861,-1507],[2805,316],[1670,-1216],[249,-1687],[-1311,-3695],[-1451,-685],[-183,-2033],[2024,-139],[-48,-1073],[-1215,-1517],[-2098,-966],[-2751,15],[-1754,779],[-1720,-1740],[-2676,672],[-1126,-500],[-1080,-2388],[-1053,958],[-1543,-594],[-1381,-1595],[-325,1332],[2109,3140],[1097,2442],[2921,99],[1953,3174],[-2388,-2076],[-3632,2057],[-838,-660],[-1000,1505],[2442,1879],[1119,2040],[-336,2214],[-1616,-648],[1589,2446],[2625,1041],[669,2003],[160,2634],[-829,-293],[-1120,2013],[375,2939],[-1455,-1083],[-3271,454],[1275,3814],[-598,1172],[132,2086],[-1505,-1666],[-1061,-2414],[440,4103],[1170,4164],[-1289,-1339],[-1334,1102],[1585,3049],[-128,3843],[1667,2259],[-162,1526],[5593,679],[-157,-707]],[[589039,490923],[-572,-747],[-84,1557],[656,-810]],[[592152,491925],[-899,584],[683,564],[216,-1148]],[[258356,772744],[-801,-2405]],[[257555,770339],[-323,707]],[[257232,771046],[1124,1698]],[[268828,776662],[1113,441]],[[269941,777103],[234,-736],[2217,752]],[[272392,777119],[160,-2629]],[[272552,774490],[-3724,2172]],[[256275,785735],[-1336,-1321]],[[254939,784414],[-604,-1184]],[[254335,783230],[-490,967]],[[253845,784197],[615,1271]],[[254460,785468],[1815,267]],[[516113,815548],[-838,-971],[-918,455],[1358,1238],[398,-722]],[[189596,872516],[-2362,-2950],[-1044,1470],[3406,1480]],[[581880,871836],[-1354,-975],[-795,1658],[1719,282],[430,-965]],[[309615,809192],[-402,-1402],[-1162,1505],[715,1124],[849,-1227]],[[708031,725294],[-1442,-438],[470,-803]],[[707059,724053],[-1499,-1179],[-646,387],[-3185,-349],[-2784,-2329],[-1209,-2336],[673,-1235],[536,-3855],[-1819,-3866],[239,-2848],[-1766,-588],[-1170,600],[-352,-913],[1156,-3132],[-1011,-1519],[-1163,-548],[-722,-3475],[105,-2943],[-1140,-1792],[-754,999],[-1212,0],[-1619,-1756],[443,-963],[-1252,-747],[-1008,520],[-1464,-2331],[-612,-6378],[-3004,-1636],[-1595,30],[-1174,-1023],[-1475,629],[-3031,-532],[-4536,2668]],[[669009,681613],[724,1598]],[[669733,683211],[1889,4168],[-344,3262],[-2332,668],[24,4468],[-747,5264],[990,2176],[-1128,792],[-70,2701],[1122,1331],[-454,1178],[625,803],[864,5722]],[[670172,715744],[779,-959],[1226,-83],[900,-1617],[2080,1629],[144,2209],[2094,1148],[1802,1945],[848,4688],[2051,706],[584,1884],[2103,-1308]],[[684783,725986],[1519,-81],[1917,-963]],[[688219,724942],[857,-1318],[2480,2224],[846,-1284],[631,2634],[2109,659],[-102,1541],[1845,3152],[1047,-885],[63,-2302],[760,87],[-331,-4773],[457,-2338],[568,-228],[3915,4231],[1415,61],[80,-1108],[1417,1088],[1110,-124],[645,-965]],[[566573,440308],[223,-3162],[-210,-1365],[56,-4659],[-303,-2233],[224,-1122],[-5511,-74],[2,-17504],[475,-3801],[429,-547],[2988,-5635]],[[564946,400206],[-5455,-2133],[-1865,-113],[-979,784],[-3657,413],[-996,678],[-893,1800],[-7308,58],[-5077,5],[-1484,2257],[-840,238],[-1537,-1453],[-1483,262],[-753,-478]],[[536300,469971],[3696,-165],[5324,160],[1118,-2226],[-24,-1364],[765,-4655],[1532,-4849],[3103,828],[1910,-181],[519,4871],[369,636],[2606,604],[25,-2030],[3176,-164],[252,-684],[-171,-2633],[321,-2818],[-184,-4902],[75,-2523],[948,-2644],[303,-3855],[-358,-1191],[280,-1789],[784,819],[1655,-111],[676,583],[1205,-221],[368,841]],[[533384,475069],[1017,2282],[1379,1031],[533,-1124]],[[536313,477258],[-1726,-2587],[145,-3699],[-806,-372]],[[555733,756786],[1170,-1918],[225,-2072]],[[557128,752796],[-215,-3561],[700,-2177],[620,-328]],[[558233,746730],[186,-1133],[-1038,-3206],[-962,-818],[-289,-1931],[-571,332]],[[553728,752769],[-3,1422]],[[553725,754191],[148,-125]],[[553787,755260],[-14,-11]],[[553773,755249],[822,2019],[1138,-482]],[[656633,652705],[-901,-1424],[-745,766],[-96,-3705],[623,-1063],[-1215,-426],[-109,-1581],[-858,-4087],[-39,-1959]],[[653293,639226],[-226,-489],[-7081,1844],[-2674,6790],[-67,1228]],[[655778,659124],[179,-2205],[425,-236]],[[309296,179739],[65,13040]],[[325969,372995],[1214,-2244],[794,-2648],[3023,-4732],[2632,-1395],[1443,-2135],[2799,-2994],[1510,-1049],[718,-1999],[-1777,-5378],[32,-1472],[-1251,-3354],[102,-701],[1213,243],[4809,-1661],[758,1376],[1249,-553],[416,1569],[2054,2949],[410,2035],[172,4341]],[[348289,353193],[1281,314],[732,-864],[611,-3295],[-464,-5309],[-1358,-1791],[-1524,-1041],[-627,-1585],[-1733,-1999],[-1389,-3158],[-1981,-5081],[-1862,-3513]],[[339975,325871],[-732,-2389],[172,-1585],[-992,-6008],[-93,-3549]],[[309879,194532],[-49,393],[-4164,1672],[-5440,110],[-1359,2659],[188,5089],[-2258,-334],[-967,3631],[-209,3213],[319,1594],[906,79],[427,1919],[1020,1089],[17,1621],[876,1719],[-625,2090],[478,2273],[1225,1724],[-98,2195],[680,1497],[-501,2476],[678,1225],[-318,2221],[1090,2064],[-1676,2601],[1933,168],[135,1907],[-1687,344],[388,2687],[-624,2900],[343,1619],[-1014,1048],[61,4098],[1010,1166],[-418,2671],[-58,5681],[658,2112],[-342,939],[775,3402],[316,3654],[1317,1465],[-213,4131],[-387,1652],[137,3839],[-205,1604],[379,1895],[1808,2737],[-182,4358],[501,3515],[661,2560],[554,453],[-116,2920],[207,2652],[-556,73],[-416,4738],[-1154,5345],[182,2495],[995,4195],[570,486],[79,3490],[-275,2637],[552,1308],[475,4086],[1341,2896],[911,4568],[1390,745],[-654,3019],[463,2161],[-516,3958],[600,2332],[-493,1506],[866,2641],[2483,2122],[965,6117],[-517,1064]],[[313347,369511],[1342,3587],[963,607],[403,1844],[1247,-1760],[1982,-19],[1256,-746],[778,-3548],[970,4473],[438,398],[2709,48],[534,-1400]],[[629140,735218],[-1045,-171]],[[628095,735047],[-1011,4059],[-1605,45],[-392,1153],[-731,-365]],[[624356,739939],[-1331,1995],[-1609,748],[-391,1871],[426,1405],[-786,2296]],[[620665,748254],[4338,1089]],[[625003,749343],[1628,-2630],[-587,-1238],[1635,-2395],[-1069,-1518],[1728,-2269],[490,-1257],[312,-2818]],[[547091,792639],[-243,-1257],[783,-2256]],[[547631,789126],[-224,-1768],[-1433,236],[-271,-4387],[-1001,-852]],[[544702,782355],[-376,-1098],[-2658,-307],[-1381,-1238],[-2232,611]],[[538055,780323],[-3644,1082],[-608,2248],[-2569,-631],[-609,-1058],[-1590,402]],[[529035,782366],[-2424,1140]],[[526611,783506],[-146,1264]],[[526465,784770],[74,1425]],[[527029,786288],[-98,96]],[[526931,786384],[1715,-1361],[327,1349],[1529,-847],[3412,1897],[1324,-290],[912,-1134],[-555,4045],[2391,2146],[388,1445]],[[538374,793634],[651,-975],[1784,-19],[780,2279],[3014,-1357],[1168,269],[1320,-1192]],[[628095,735047],[-1763,761],[-1840,3816]],[[624492,739624],[-136,315]],[[635746,732426],[-767,-144],[-1582,2417],[608,947],[-294,1975],[517,514],[-907,1688],[-619,-210],[-3562,-4395]],[[625003,749343],[777,940],[1422,-1334],[1847,-913],[596,1283],[-1362,2193],[688,1386]],[[628971,752898],[888,-464],[1421,-2948],[1667,-606],[1977,3743]],[[584871,490497],[-360,-1430],[252,-1635],[737,-400],[28,-1715],[-1015,-1862],[-771,-2941],[-1390,-2187]],[[582352,478327],[16,203]],[[581384,484839],[-244,85]],[[581140,484924],[38,1702],[-583,1975]],[[580595,488601],[135,697],[909,-1220],[1328,546],[172,2233],[1732,-360]],[[515815,805530],[552,-132]],[[516367,805398],[282,-12]],[[516649,805386],[1030,-2573],[-689,-1157]],[[516990,801656],[-1035,-1192],[126,-2260]],[[516081,798204],[-784,-162],[-1776,1643],[-21,2060],[-1901,-1041],[-3,1696],[-1410,464],[-1556,2693],[-742,-400],[-875,2283]],[[509305,809102],[1534,-1008],[900,1060]],[[511739,809154],[770,523],[2704,-1124],[973,-945],[-371,-2078]],[[509987,574011],[-300,-1782],[636,-1871],[328,-2798],[-718,-2009],[-52,-2138],[-645,-764],[-778,-4115],[-751,-209],[-246,-6960],[246,-6885],[-191,-2029]],[[504506,541548],[432,461],[-556,2327],[131,1836],[-69,12162],[-488,1392],[-262,4218],[-1528,2148],[335,3754]],[[502501,569846],[1462,2689],[1538,-170],[1135,2836]],[[506636,575201],[-65,1924],[1423,864],[1993,-3978]],[[500603,593059],[-148,-2454],[1262,-4703],[1619,-2049],[-591,43],[-3,-1913],[1605,-2408],[1413,465],[423,-1468],[-374,-1115],[827,-2256]],[[502501,569846],[-1157,-7],[-1535,732]],[[499809,570571],[-641,304],[-1117,-1054],[-5912,56],[-236,-2406],[356,-1128],[57,-4407],[195,-1047]],[[492511,560889],[-336,-329],[-1131,2782],[-1816,-3],[-1262,-1476],[-1772,1684],[-361,1846],[-1177,1093]],[[484656,566486],[92,3651],[530,969],[32,3685],[1362,1210],[1026,1810],[-145,1982],[705,720],[-284,1927],[772,1561],[1320,-1116],[762,513],[287,2323],[781,40],[120,1607],[1158,1915],[955,-626],[389,1707],[571,175],[1995,1976],[803,1352],[1457,69],[1259,-877]],[[757152,634925],[157,-3980],[-836,791],[-419,-869],[401,-2970]],[[747364,635607],[-862,7959],[-482,1409],[461,3297],[-1633,1510],[-339,842],[351,1699],[454,-195],[397,1817],[1205,35],[-336,1754],[-880,497],[-1021,1860],[1204,3729],[457,-1339],[2409,-1697],[192,1247],[566,-1625],[-24,-3768],[1737,-875],[4473,69],[1163,-1335],[-603,-290],[-521,-3085],[-519,-1061],[-1416,-603],[-574,-2565],[430,-3295],[845,-739],[884,3110],[-23,1075],[879,-15],[321,-4470],[361,-1443],[232,-4191]],[[577817,753361],[-1332,-286],[-666,940],[-1888,-679],[-818,-1471]],[[573113,751865],[-708,-257],[248,-1412],[-2511,-1133],[-2036,1830],[-2453,-982],[-1998,-299]],[[563655,749612],[249,2255],[-469,1639],[-1369,1898]],[[562066,755404],[499,753],[-158,2377],[1417,2048],[-1173,1579],[-372,3276],[790,1365]],[[563069,766802],[899,-947],[-305,-1443],[849,234],[6313,-1203],[1996,1993],[2420,949],[2215,-1068],[460,-976],[1487,-475]],[[552797,770541],[949,71],[-547,-3427],[1200,-1535],[-941,-464],[675,-1549],[-816,-1009]],[[553317,762628],[-466,-1427],[-979,-366],[-640,-1554],[-21,-2421]],[[551211,756860],[-2135,1999]],[[548847,759103],[-865,3006],[-1556,1974],[-1387,2584],[-233,1533],[-1094,1730],[143,2448],[1404,-1008],[659,1230],[3561,-820],[2361,-4],[957,-1235]],[[578188,837332],[1797,-1187],[1612,-22],[297,-1505],[2088,951],[1870,-1630],[197,-3078],[-497,-1583],[895,-799],[785,-2681],[1079,-829],[-105,-1454],[1933,-697],[706,-2112],[-1562,-1453],[-2012,622],[-442,-1063],[768,-1294],[117,-2880],[517,-1252]],[[588231,813386],[-2174,-324],[-1244,-2665],[32,-1963],[-1066,1260],[-2262,-563],[-585,1389],[-951,-633],[-1693,578],[-2538,33],[-356,822],[-3380,955],[-4343,-272],[-2101,-2070]],[[565570,809933],[131,3095],[-1266,1283],[1800,2413],[118,2152],[-1118,5405]],[[565235,824281],[3565,206],[2164,2116],[867,3481],[2545,2096],[-883,411],[377,1926]],[[573870,834517],[1275,965],[1457,-188],[1586,2038]],[[253072,598860],[-954,23],[211,11377]],[[252329,610260],[78,924],[908,-31],[788,2846],[631,157]],[[338445,385253],[-57,2053],[-2529,3151],[-2546,-67],[-4860,-2061],[-445,-2429],[-998,-3004],[-1,-2984],[-1040,-6917]],[[313347,369511],[-1901,-7],[-303,4537],[-550,2598],[-29,1886],[-936,2231],[94,1845],[-681,910],[130,4369],[654,1708],[-1404,2753],[-349,5437],[-609,636],[-549,2589]],[[306914,401003],[-317,1812],[518,663],[1114,3294]],[[308229,406772],[86,-108]],[[307332,412821],[-12,12]],[[307320,412833],[534,1615],[-562,1622],[388,2167],[985,2360],[-538,3056],[252,1105],[13,3652],[815,2240],[-2481,9184]],[[306726,439834],[2028,-352],[336,-660],[915,614],[907,1871],[972,119],[387,1049],[1308,1404],[1060,1739],[1295,885],[2410,673],[230,-3202],[-352,-1975],[323,-2598],[5,-2456],[915,-3174],[1331,-1634],[258,-1119],[2319,-469],[664,-955],[775,65],[839,-1944],[1637,-809],[1073,-2321],[1980,213],[1585,-1778],[89,-2340],[488,-2570],[71,-2786],[-861,-56],[947,-2259],[185,-4679],[4549,-350],[535,261],[-348,-2168],[208,-3460],[1565,-1647],[718,-4544],[-629,-4749],[-920,-3931],[752,-1393],[-830,-1096]],[[343103,516223],[1286,-592],[290,1169],[-594,1540],[537,1287],[571,-655],[2088,1135],[1007,-1605]],[[348288,518502],[1350,-1219],[1007,1385],[2230,-1015],[734,1068],[1972,7928],[939,2129]],[[351748,304813],[-452,1152]],[[351296,305965],[18,-30]],[[352392,311289],[-83,-134]],[[352309,311155],[-1203,1592],[-444,2051],[-1275,1195],[-1020,2192],[-1852,1538],[-841,2071],[-1243,-1204],[-476,2670],[-1824,3088],[-1060,-1043],[-1096,566]],[[348289,353193],[15,849]],[[348304,354042],[381,1259],[573,6839]],[[349258,362140],[-996,1501],[-992,-960],[-1066,-98],[-477,2430],[-221,5388],[-643,2156],[-946,156],[-570,1117],[-1506,-1058],[-2830,960],[349,6583],[-915,4938]],[[306726,439834],[-1061,129],[-1199,-762],[-695,286],[15,9077],[-1669,-2890],[-2622,-223],[-548,2924],[-2307,585],[654,2478],[-1597,3834],[-629,2426],[153,912],[-783,1342],[783,1462],[-105,2390],[1438,2025],[292,1301],[-278,1457],[710,2747],[258,3034],[523,329],[2372,3334],[2420,912],[483,1049],[1097,138],[460,-895],[759,385]],[[305650,479620],[1571,18018],[-742,4221],[-1120,2035],[47,4251],[1616,897],[827,-561],[31,1355],[-551,1185],[-1363,-27],[10,3846],[4644,65],[-48,1584],[567,-1389],[1362,2105],[410,-131],[727,-2787],[-10,-2401],[605,77]],[[314233,511963],[1595,-2791],[1723,1371],[578,-1731],[1027,2469],[1034,861],[1713,2168],[220,1690],[1782,1884],[13,1122],[-1832,671],[31,1638],[-503,2388],[-6,2267],[-1658,3821],[1562,-545],[650,-1251],[2019,-41],[906,-1945],[567,468],[145,2044],[838,822],[715,-345],[1664,1122],[761,1357],[770,109],[1107,2721],[-382,1229]],[[331272,535536],[1666,218],[421,-924],[-439,-3256],[1237,-901],[423,-2652],[-843,-2050],[65,-1412],[-453,-3906],[664,-2463],[-3,-2213],[1459,-3108],[1024,-1021],[974,479],[475,1795],[2073,691],[1321,1836],[784,-787],[983,361]],[[819833,533745],[518,-3074],[-610,57],[-223,3017]],[[819518,533745],[-778,-1076],[260,-1925],[-644,-2187],[-1513,3369]],[[816843,531926],[317,-10]],[[754532,669180],[-103,-1199],[1100,-637],[230,-3171],[-1116,-669],[-2589,-179],[-1094,703],[-1617,-1119],[-2517,1540],[94,2101]],[[746920,666550],[1793,4688],[1234,1207],[1020,-398],[12,-970],[2014,-627],[411,574],[1055,-708],[73,-1136]],[[570163,399300],[-97,-721],[1492,-4348],[1131,-5268],[1417,-2100],[1509,-1499],[164,-1972],[1164,-308],[-84,-3161],[1045,-3014],[2755,-1412],[193,-1507],[716,-760]],[[581568,373230],[-652,-114],[-806,-1586],[-1749,-1260],[-888,-2253],[-2510,-3737],[-422,-3177],[-1064,-2025],[-1499,-976],[-912,-5088],[-390,-641],[-1932,-610],[-2373,1283],[-1744,1980],[-1075,-1133],[-663,-3633],[-1526,-3016],[-1235,-1623],[-2518,31],[-314,2400],[523,1652],[-61,1477],[-1244,5248],[-1013,1499]],[[555501,357928],[-9,16450],[2760,0],[9,21810],[2881,712],[3024,1120],[552,-106],[783,-2521],[2162,2813],[477,-441],[1051,1370],[972,165]],[[563500,569410],[1256,-3150],[928,-3348],[-66,-2857],[-429,-1338],[192,-1771],[1694,-890]],[[567075,556056],[401,-2217],[1560,-911],[1095,-2447],[-159,-1216],[1941,-2692],[1314,-2545],[-148,-1067],[571,-2287],[1581,-1732],[889,-3956]],[[576120,534986],[-801,526],[-814,-803],[-3603,1479],[-766,-1703],[-1343,-560],[-1239,380],[-2507,-1961],[-837,437],[-1000,-535],[-927,-3032],[-2042,868],[-415,-217],[-2721,1291],[-921,2174],[-1166,1538],[-849,227],[-1201,-1399],[-1392,-3755],[119,-4616]],[[551695,525325],[-378,856],[-871,-729],[-2008,1094],[-2124,-885],[-492,-1933],[-77,-2234],[-792,-3328]],[[544953,518166],[-333,3783],[-801,1295],[-1795,4145],[-295,3150],[-871,1819],[-406,3640],[150,3467],[-516,1028],[856,1428],[1407,5829],[651,1541]],[[543000,549291],[1013,-287],[1483,1234],[463,1078],[665,-1864],[2402,2563],[2617,458],[1436,3527],[-612,1384],[714,748],[1453,29],[1871,629],[1198,1650],[1363,3371],[1283,2322],[-54,1234],[935,1469],[1252,1028],[1018,-454]],[[313542,772321],[-966,631]],[[312576,772952],[63,1967]],[[312639,774919],[-31,200]],[[311702,775814],[-38,-22]],[[311664,775792],[-16,7865],[-1191,1559],[-1648,-845]],[[308809,784371],[-1151,1538]],[[307658,785909],[-2124,-4467]],[[305534,781442],[-802,-4756],[-1671,-3814]],[[303061,772872],[-1193,164]],[[301868,773036],[-528,-1674]],[[301340,771362],[-8865,-22]],[[267330,873831],[-420,723]],[[292475,771340],[-1307,-619]],[[291168,770721],[-1718,-2421]],[[289450,768300],[-20,24]],[[279963,760904],[422,241]],[[280385,761145],[-13,-1049]],[[270430,756850],[633,2722]],[[251257,789167],[-84,-67]],[[251173,789100],[-3508,1179]],[[247665,790279],[-1884,-843]],[[245781,789436],[-4105,3279]],[[241676,792715],[-1975,-511]],[[239701,792204],[-2537,1286]],[[237164,793490],[-255,716]],[[236909,794206],[-395,2614],[-834,385],[-19,-2240],[-6577,10],[-10660,-1]],[[218424,794974],[-4738,0],[-7106,0],[-10660,0],[-8290,0],[-5922,0],[-5922,0],[-8292,0]],[[167494,794974],[-8574,0]],[[138819,835824],[-202,1310]],[[138617,837134],[-4105,2900]],[[134512,840034],[-691,-52]],[[133821,839982],[-576,2586],[-4969,9944],[-3121,3456]],[[125155,855968],[-298,1719]],[[124857,857687],[-1180,1271],[-2349,-1115]],[[121328,857843],[-714,-2681],[-2388,-1476]],[[118226,853686],[-430,1914]],[[117796,855600],[-4423,5079],[295,1541],[-2484,-951]],[[111184,861269],[-2857,694]],[[108327,861963],[0,55397]],[[526465,784770],[146,-1264]],[[529035,782366],[-61,-1865],[-955,295],[-410,-1411],[-1912,-444],[-826,-2706],[-1620,3644],[-1618,-3101],[-2130,24]],[[519503,776802],[-732,2903],[-913,87],[-951,-1680],[-73,1667],[2612,5298],[146,989],[1562,611]],[[521154,786677],[3515,378]],[[524669,787055],[685,84]],[[525354,787139],[153,0]],[[304393,396029],[1367,827],[206,2976],[948,1171]],[[868915,771709],[-4,489]],[[866863,773017],[-131,-309]],[[866732,772708],[-479,546],[-1125,-2031],[-1011,-439],[480,-4967],[17,-3784],[-536,-3144],[-1788,-1038],[284,-1135]],[[862574,756716],[-796,2111],[-950,631],[-496,-3100],[-1128,-364],[-1084,-2223],[-2440,-301],[683,-2516],[-499,-1028],[-2588,842],[-767,1479],[-841,-830],[-307,-1676],[-1392,-2686],[-3055,-2636],[-1465,-2700]],[[817405,638260],[-696,-172]],[[815489,636816],[-446,-704]],[[799923,632140],[-474,813],[-1252,-215],[-958,1686],[-952,506],[-354,2468],[678,2272],[-662,767],[-1942,85],[-1576,2503],[-1141,-1238],[-192,-1334],[-1177,-1227],[-883,286],[-386,-1268],[-819,1444],[-414,-1094],[-475,990],[-819,-1845],[-1356,1706],[-1082,-2143]],[[783687,637302],[-1267,492],[-408,-1236],[589,-2531],[-88,-4007],[-1335,436],[-237,2037]],[[780941,632493],[-51,1058],[-1637,-1706],[-879,29],[-657,1413],[-169,1934],[-2013,580],[534,4142],[-123,1605],[-1325,565],[-88,2566],[-420,1288],[425,1474],[-2270,-149],[-1256,-915],[350,1302],[-442,2138],[800,4503],[981,2031],[1258,1375],[295,4483],[-224,5860],[-1139,537],[-395,2839],[-1558,2179],[-599,-1731]],[[770339,671893],[-2000,1433],[-891,-283],[831,2083],[-404,1700],[-759,-835],[494,1778],[-846,1406],[-1443,-1426],[-266,-901],[-1807,720],[-407,809],[-2002,-3017],[-1933,-1258],[-242,-1117],[-1160,-1512],[-104,-1174],[-1907,-1295],[-961,176]],[[746920,666550],[-396,1219],[277,2055],[-632,1322],[-1420,-1311]],[[744749,669835],[-2690,-191],[-1631,1463],[-406,-928],[-915,918],[-347,-920],[-765,2068],[-1544,229],[101,1636],[-1235,20],[-1349,1873],[-354,1826],[-1438,-215],[-1189,2542],[-837,419],[-1932,2558],[-321,1254],[-1739,64],[-450,-1448],[-680,422]],[[725028,683425],[-912,1483],[-1363,910],[-741,1898]],[[722012,687716],[-22,32]],[[721990,687748],[-1520,1101]],[[720470,688849],[-85,152]],[[720385,689001],[-645,1760],[-875,-646],[-201,3519]],[[718664,693634],[-916,3746],[865,457],[606,-1415],[834,846]],[[720053,697268],[-8,373]],[[720045,697641],[-226,3602]],[[719819,701243],[-63,322]],[[719756,701565],[-863,1620],[-136,3483],[605,833],[-833,1717],[-1080,805],[-749,3537],[-560,1383]],[[716140,714943],[-31,68]],[[716109,715011],[-981,-120],[-1888,1102]],[[713240,715993],[-598,1335],[-1121,-344],[-675,1538],[193,1741],[-372,1584],[-1156,524],[-215,1038],[-2237,644]],[[708031,725294],[631,913],[-623,1278],[-415,5383],[-1298,887],[-1322,-313],[-17,2341],[-455,2647]],[[704532,738430],[786,934],[214,2587],[2361,1788],[66,880],[1994,662],[261,-1774],[2230,851],[954,3157],[3611,553],[664,1753],[2586,2436],[2562,1479],[-18,934]],[[722803,754670],[-124,2817],[1040,1232],[-414,1005],[1099,702],[-1196,5543],[279,3844],[-1273,303],[172,1240],[2205,727],[2330,1304],[826,-1111],[1360,-226],[288,1889],[-711,459],[1953,9870],[2740,-1276],[1807,11],[332,-841],[1941,1380],[477,1133],[-363,3916],[621,2780],[2222,852],[566,2845],[1582,456]],[[742562,795524],[1366,453]],[[743928,795977],[-198,-1664],[657,-1934],[1493,-1010],[1474,-2263],[1426,8],[2089,-1942],[509,-2316],[1038,-1959],[455,-2521],[-89,-2922],[-945,-3025],[599,-1951],[1963,-707],[3343,-242],[2414,-798],[2932,-3260],[1773,-431],[1561,-6349],[1315,-2879],[2278,411],[6283,-1313],[1434,647],[4806,-1253],[719,-1481],[3056,-1244],[1773,-1508],[2185,744],[0,-1293],[1345,-374],[597,844],[4370,3263],[3892,939],[3533,51],[2659,1883],[1686,3363],[2571,2192],[-1475,3886],[609,2724],[768,1404],[1427,-35],[515,-833],[2750,-1019],[1232,1167],[1352,2500],[3233,555],[1555,2001],[894,2926],[2140,427],[2709,2104],[1488,256],[2396,-915],[532,1493],[-519,1731],[-1748,2986],[-1621,1955],[-2027,23],[-1161,-1989],[-1639,1288],[-1471,-68],[-924,-1014],[-946,1529],[1100,4410],[2026,6721]],[[824119,799896],[3306,-1839],[1606,1961],[2246,1315],[-268,2012],[2508,7077],[1708,2206],[-70,3518],[-1635,391],[75,915],[1693,2279],[4538,1855],[3898,153],[2976,-2233],[730,413],[1594,-956],[1844,-3806],[1368,-5297],[332,-2403],[1061,-2324],[-2,-1506],[789,-1449],[-243,-1989],[1381,-1805],[1956,186],[1156,-1410],[1050,159],[1939,-2946],[992,-180],[697,-3080],[-256,-1266],[808,-2584],[2173,-65],[2158,521],[402,1058],[2116,889],[2291,1637],[751,-306],[523,-3592],[-1623,-2448],[96,-1032],[-932,-3727],[-15,-1488],[-1876,-4461],[-201,-2157],[-844,-383]],[[492511,560889],[-141,-2202],[406,-1832],[263,-3506],[-789,-1640],[-545,-4307],[-694,-2356],[98,-2719],[662,-4178],[468,-254],[-61,-2649],[-566,-132]],[[479041,530496],[-66,4321],[385,1445],[-67,3062],[-952,792],[-254,1539],[-1986,1617],[752,1741],[100,1614],[-527,2870]],[[476426,549497],[707,-10],[598,3484],[-665,645],[53,1196],[1544,-268],[-750,2230],[480,1742],[-327,1985],[-670,473],[2,3118],[405,832]],[[477803,564924],[915,1570],[1926,-1488],[763,1026],[109,1819],[685,-498],[406,898],[55,-2635],[575,-500],[530,1153],[889,217]],[[544953,518166],[-344,-3518],[-883,1414],[-2331,576],[-1162,845],[-3307,40]],[[536926,517523],[-203,562],[-5200,257],[-55,-784]],[[531468,517558],[-3747,1],[-497,811]],[[523766,532889],[681,2620],[720,4809],[1666,3097],[333,1352],[1010,1400],[941,-623],[344,1018],[1184,-2164],[336,-1540],[1106,1537],[79,1135],[782,1348],[-261,923],[690,1881],[604,4103],[473,1856],[1119,1724],[342,3198],[683,671],[262,2942],[738,3370],[991,3170],[1854,2087],[187,3651],[-300,1123],[-893,507],[-371,4116]],[[539066,582200],[1256,-585]],[[540322,581615],[681,-1920],[889,-4800],[-143,-4336],[684,-4480],[1052,-2071],[-2275,-392],[-1646,226],[-739,-1708],[986,-2891],[2178,-3828],[908,-4180],[103,-1944]],[[576120,534986],[1069,-2752],[1122,-1744],[654,-155],[832,1072],[1179,-692],[883,1325],[576,-148],[1439,-3584],[871,-867],[917,-2043]],[[585662,525398],[-235,-2660],[258,-1154],[-328,-2320],[1484,-1752]],[[586841,517512],[-11,-38]],[[584660,512056],[-1486,-2485],[-23,-1897],[-698,-3669]],[[582453,504005],[-110,3]],[[582340,501712],[2,5]],[[582342,501717],[-184,-5222]],[[582158,496495],[-988,-1767]],[[581170,494728],[-66,250]],[[580139,490215],[21,11]],[[580160,490226],[435,-1625]],[[581140,484924],[-41,14]],[[584945,456249],[-9,-232]],[[584936,456017],[-4664,-1572],[55,-1274],[-1437,-3106],[637,-3593],[25,-4964],[-783,-4821],[349,-1950],[1616,-3180],[1009,-488],[367,1355],[654,279],[0,-7331],[-670,852],[-1499,-710],[-1824,5254],[-2290,1699],[-936,3496],[-686,-1740],[-981,-434],[-1584,486],[-1880,1582],[-83,2288],[-2734,-798],[-42,1776],[-982,1185]],[[536313,477258],[950,-1200],[751,881],[88,1388],[622,-179],[1160,1097],[145,-3151],[826,-299],[2478,5041],[757,573],[762,2785],[196,2570],[-6,5051],[904,2000],[1205,4149],[845,831],[1317,2669],[-80,1609],[454,3031],[41,5237],[432,2469],[40,2835],[1163,5398],[332,3282]],[[530917,481515],[1039,2346],[958,-1045],[236,2240],[-1101,2855],[188,2927],[1275,-414],[1061,489],[-40,2376],[1004,-17],[551,-2260],[1314,-486],[747,1522],[425,-1938],[557,-8],[824,3418],[199,2825],[-125,2613],[194,2096],[-1723,2459],[68,2335],[563,2047],[964,1630],[-704,3310],[-916,287],[-1603,-1053],[-309,2412],[363,3042]],[[301889,574992],[-1773,-1158],[-807,-2784],[-548,-487],[-1176,-3691],[-381,-4160],[-972,-3331],[828,194],[728,-892],[363,-2852],[692,-1455],[-74,-5493],[654,-501],[875,-2251],[2443,24],[995,524],[1555,-858],[1822,-4758],[2687,128],[2511,505],[357,-1281],[-1071,-4473],[-84,-4524],[538,-3807],[973,-2657],[-1454,-3099],[600,-588],[1133,-2390],[930,-6914]],[[305650,479620],[-1038,2498],[-1099,196],[1837,6109],[-61,546],[-2274,2604],[-1340,-684],[-988,1074],[-644,-1030],[-1142,-606],[-1366,121],[-742,772],[-118,2654],[-832,813],[-466,2631],[-1617,1649],[-477,2310],[-1066,2255],[-1341,553]],[[290876,504085],[-1653,1527],[-1198,1762],[-510,-1262],[-1182,196],[-1396,926],[-125,1254],[-2346,2427],[-1521,2424]],[[283608,547547],[469,2853],[404,-994],[1085,2544],[-784,3116],[289,947]],[[270656,561454],[-1045,-756],[-1,-2305],[553,-642],[-488,-1252],[154,-1699],[-450,-815],[400,-1454]],[[261820,570254],[343,725],[1978,-1417],[578,633],[980,-428],[500,-1182],[992,-220],[470,1031]],[[594456,712459],[-1330,-157],[-394,735],[-1864,49]],[[541137,806029],[512,920],[1002,-1200],[2959,-1412],[-582,-888],[1302,-1932],[863,826],[-304,1127],[2763,-2695],[1910,-550],[749,-2184]],[[552311,798041],[-1865,-1501],[-1117,-2187],[-1584,-162],[-654,-1552]],[[538374,793634],[-3286,4113],[-987,3444],[489,1821],[5323,3251],[1224,-234]],[[527054,829528],[17,-108]],[[539583,823049],[24,-14]],[[539607,823035],[433,-2642],[-766,-2078],[1335,-2395],[370,-2647],[-419,-1477],[1152,-3435],[-575,-2332]],[[526931,786384],[-142,138]],[[521154,786677],[-87,2795],[705,3387],[823,2000],[-1899,1057],[-1987,51],[-1086,1730]],[[517623,797697],[397,2049],[-1030,1910]],[[516649,805386],[-276,1385],[830,2990],[-680,1620],[2204,880],[815,2780],[450,5344]],[[524116,829329],[-32,661]],[[524084,829990],[2970,-462]],[[620127,572847],[-898,-2965]],[[619229,569882],[-1014,483],[-2109,-595],[-89,3606],[1700,5198]],[[617717,578574],[810,-533],[1241,1968]],[[526959,830136],[95,-608]],[[524084,829990],[-25,488]],[[300643,611589],[18,1790],[-662,1520],[714,800],[239,2357],[-339,3480]],[[523823,723550],[-1021,-2619],[388,-754],[-286,-2947],[412,-3949],[-412,-2784],[-2033,-3872],[-38,-1469],[642,-3341],[1333,-2025],[340,-2270],[1974,-2792],[1318,-10918]],[[526440,683810],[-579,-677],[917,-2836],[562,-3966],[-75,-2410],[279,-4589],[-468,-2695],[408,-2861],[-97,-1753],[-1023,-1293],[-119,-1579],[1534,-4355],[76,-1665],[633,-2726],[1195,-235],[2363,-1543],[1198,-4579]],[[533244,644048],[-5754,-7235],[-6708,-8434],[-4570,-8259],[-4469,-1992]],[[511743,618128],[-2297,-915],[-819,958],[417,1545],[-146,2244],[-2215,1625],[-519,1089],[-1483,774],[-207,1050],[-1236,1551],[-57,1687],[-7683,10714],[-8895,12352]],[[486603,652802],[-6023,7672],[-4701,5897]],[[475879,666371],[0,2195]],[[475879,668566],[64,6293],[2709,3738],[1639,1633],[1277,-334],[374,1424],[2922,876],[1335,3012],[1793,1383],[1939,2174],[-580,782],[19,2750],[2248,1021],[240,1234],[1340,518],[3259,-243],[582,2247],[-1234,2425],[-470,2613],[-323,8491],[-1178,2087]],[[290876,504085],[-949,-96],[1008,-2562],[38,-2349],[-440,163],[-452,-3596],[-1442,-3565],[-1637,-2546],[-3282,-2482],[-1346,-2462],[-207,-2249],[-721,-3252],[-19,-1403],[-1084,-2536],[-707,372],[-854,2801],[-1392,942],[-678,-993],[-351,2335],[919,1136],[-404,2903]],[[594994,690286],[131,-677]],[[595125,689609],[1831,-10255]],[[602420,635036],[-6380,-3],[-8723,-3],[-5194,-4],[-6367,2],[-6367,2]],[[569389,635030],[0,42574],[-705,6331],[687,3116],[-336,3308],[827,1897]],[[617717,578574],[-1184,2464],[-520,1787],[-1117,1871],[-1648,3819],[-1522,1699],[-1916,625],[-927,-339],[-344,881],[-1583,-1207],[-1723,2535],[-869,-4166],[-872,1805],[-647,-1077],[-1389,-90]],[[601456,589181],[-271,5185],[264,700],[1089,6197],[-73,1946],[337,2573],[1117,16],[1032,2348],[1308,751],[989,2490]],[[495015,761882],[1066,-991],[-194,-1001],[3281,-1456],[717,-807],[1868,3],[182,921],[2032,-1477]],[[504739,756526],[-772,548]],[[504739,756526],[906,-888],[1720,-77],[1555,537]],[[479427,724985],[-272,2406],[1337,2720],[-890,2445],[959,3549],[-485,467],[-1009,3118],[1386,311],[629,3727],[-328,3946],[1989,3098],[-917,832],[-211,1599],[-1469,229],[-712,-873],[-2281,368],[32,1409],[-1567,-1141]],[[577812,857129],[-955,-2975]],[[576857,854154],[-403,147]],[[576732,848420],[54,9]],[[576786,848429],[-809,-2889]],[[575977,845540],[-2276,17],[-1504,1820],[-2445,1334],[-2190,-1143]],[[619229,569882],[-731,-2239],[506,-2478],[944,-1914],[836,-2966],[1501,-2331],[8209,-5859],[2778,0]],[[633272,552095],[-4320,-8885],[-4118,-9392],[-2643,228],[-2398,-1813],[-928,-2088],[-2132,-913],[-389,-949]],[[616344,528283],[-1842,-203],[-1266,1953],[-2564,-2498],[-966,-2342],[-3912,1141],[-3279,4519],[-2288,226],[-886,2123],[-50,3175],[-1324,879]],[[597967,537256],[-518,1071],[-1031,5849],[-1796,3350],[-1106,2638],[-1222,531],[-593,1130],[616,2636],[1997,279],[392,822],[-45,5209]],[[594661,560771],[593,3930],[-44,2389],[822,2086],[999,-91],[503,5639],[1343,4270],[1421,1120],[291,3227],[495,2103],[372,3737]],[[580460,913634],[-1375,-3161],[595,-1770],[1830,-758],[1765,-2211],[-2478,-4251],[2267,-5213],[534,-2425],[-767,-669],[-598,-3557],[1302,-1205],[98,-2364],[1099,-2046],[-1388,-1619],[3269,-3194],[981,-1912],[-690,-1883],[-4432,-6052],[-5257,-5983]],[[567098,894577],[-1122,2286],[658,3670],[-1445,3788],[474,2989],[-2379,2586],[-2181,768],[-3820,3059]],[[557283,913723],[2777,1385],[2192,-3263],[2536,-420],[1473,929],[3020,-1259],[2242,2351],[731,3925],[1427,1554],[3790,869],[3477,-2312],[533,-1175],[-1021,-2673]],[[348288,518502],[595,797],[574,2112],[-22,1898],[591,2674],[-1001,2752],[-274,2553],[-7,3131],[822,2047]],[[516081,798204],[1542,-507]],[[519503,776802],[-598,-1279],[964,-1831],[-1459,-1676],[1119,-2377],[-521,-1220],[345,-1367],[1860,-682],[-399,-2357]],[[520814,764013],[-644,-421]],[[503967,757074],[772,-548]],[[526641,510831],[4846,-191],[-19,6918]],[[482727,825162],[-1199,-177],[-1111,2071],[-849,-1701],[-2120,1737],[2499,4015]],[[620665,748254],[-1810,2705],[-893,-734],[-2657,460]],[[611050,761956],[478,888],[1383,-212],[2590,-1865],[2329,30],[3788,-2827],[485,-1069],[2538,1124],[2379,-1667],[-247,-1599],[2198,-1861]],[[499809,570571],[30,-2873],[1218,-2007],[-410,-4908],[822,-623],[-112,-3003],[-323,-546],[877,-2696],[-290,-939],[142,-4693],[-305,-2978],[588,-2360],[1250,-2152]],[[491349,534865],[76,235]],[[468362,578206],[-313,-1219],[547,-1085],[1034,1124],[710,-1811],[1118,1855],[1262,-1008],[1284,1262],[-104,1239],[980,-369],[624,-2843],[-10,-1476],[1151,-1700],[-713,-2077],[907,-267],[295,-3275],[669,-1632]],[[476426,549497],[-616,595],[-504,-2347],[-633,-278],[-962,1185],[263,1325],[-414,4186],[-695,1117],[-1431,-293]],[[471434,554987],[-1191,-888],[588,2087],[-528,3713],[-1431,3931],[-1959,90],[-1640,-775],[-706,-2895],[-756,-1599],[-736,-322]],[[458212,569531],[1002,3368],[1159,897],[1480,451],[-15,1621],[-586,998],[669,797],[-58,2140]],[[461863,579803],[1795,-239],[197,-924],[2002,-886],[2505,452]],[[453993,585214],[2924,-6],[1115,1338],[2174,-1917],[967,326],[361,-1234],[-1109,-589],[-2512,1900],[-375,-951],[-1467,-420],[-56,-999],[-2263,-14],[-317,-533]],[[453578,577913],[3158,803],[1052,1123],[4075,-36]],[[558233,746730],[1699,113],[983,1413],[1567,66],[1173,1290]],[[573113,751865],[844,-1865],[-817,-966],[1,-1684],[-811,-1349]],[[254921,597903],[-2078,-3474],[-683,-1639],[139,-1535],[-529,-1131]],[[251770,590124],[-2061,-3444],[26,-582]],[[243791,590891],[444,3133],[-311,1461],[1252,4439],[3582,15],[83,1886],[-815,1878],[-1942,3246],[1158,-21],[10,3342],[5077,-10]],[[341125,537589],[-378,-3130],[-1056,-173],[-954,-4853],[616,-2938],[787,-1914],[683,144],[261,-2929],[1404,-5014],[615,-559]],[[331272,535536],[-1763,4177],[689,1820],[-47,2846],[1188,437],[897,1049],[193,1117],[-855,457],[-239,1704],[571,1863],[1337,1424],[558,1495],[-517,1442]],[[817405,638260],[69,-246]],[[269007,593543],[-716,89],[-1256,-1266],[-1862,-954],[-897,1045],[-836,-1686],[-130,-1264],[-1607,-2769],[-704,1219],[-811,-1660],[-1115,-39],[64,-2666],[-968,-1908],[-773,-72]],[[256071,584100],[275,2450],[-1210,1034],[-921,-788],[-1772,3057],[-673,271]],[[551211,756860],[226,-751]],[[552514,776837],[644,-4357],[-361,-1939]],[[537716,774380],[2200,-210],[456,970],[869,-1054],[1368,-2],[-173,1574],[966,601],[31,2172],[2445,1773]],[[545878,780204],[2472,-3252],[1114,-952],[1531,-221],[1519,1058]],[[561477,791492],[1770,-1752],[299,-963]],[[563546,788777],[-1628,-1299],[-3475,-8801],[-2216,-792]],[[556227,777885],[-1975,276],[-1738,-1324]],[[545878,780204],[-1176,2151]],[[547631,789126],[1707,-1397],[2673,100],[188,1263],[3074,777],[1209,973],[434,1370],[2671,151],[876,-1269],[1014,398]],[[847411,448364],[-195,-316]],[[844380,449187],[165,186]],[[844545,449373],[683,-512],[450,1407]],[[845678,450268],[364,208]],[[846915,451584],[90,154]],[[847005,451738],[631,-1069],[-477,-428],[252,-1877]],[[890964,489271],[628,-15]],[[891592,489256],[0,-1148]],[[891592,488108],[4,-20988],[-314,-2334],[315,-979],[3,-13114]],[[891600,450693],[-144,200]],[[826595,529426],[-23,-50]],[[804524,516729],[69,-2445],[2367,-4460],[1200,920],[2311,-106],[857,853],[298,1752],[806,711],[1298,47],[126,-651],[1760,-1311],[779,1175],[1787,195],[791,3039],[-123,1602],[1091,1616],[-258,1883],[1023,1145],[310,2437],[7,2921],[910,2429],[3346,-69],[1316,-986]],[[720385,689001],[85,-152]],[[721990,687748],[22,-32]],[[725028,683425],[-1281,-1568],[-817,-2823],[-512,-3514],[3051,-2934],[1899,-2772],[375,277],[2070,-2339],[1547,-877],[1497,41],[728,672],[1442,-1141],[209,-1527],[1688,-1777],[765,585],[469,-1185],[1747,-387],[931,-826],[874,713],[754,-1156],[2132,414],[296,1746],[-492,2424],[349,4364]],[[770339,671893],[36,-1660],[-1211,-1741],[385,-3210],[-1035,1405],[-1677,-724],[-434,-1009],[-2157,-2663],[10,-3294],[-1606,-4726],[426,-1154],[-1152,-4306],[-459,-2639],[-2279,862],[299,-2014],[-137,-3255],[-559,-596],[-238,-1859],[232,-2121],[-549,-2112],[-765,754],[-317,-906]],[[689347,646059],[1553,636],[11,1783],[2308,44],[436,-595],[2308,1455],[470,-1068],[911,960],[10,1704],[-1099,4356],[-10,1446],[-1066,234],[-457,1206],[157,3326],[-1907,1973],[272,2193],[1601,3995],[720,1043],[927,-1754],[3147,1384],[1310,4676],[1560,1641],[1328,5365],[1188,942],[250,2026],[1223,2714],[815,836],[-320,895],[-22,3124],[638,1397],[1429,1135],[221,823],[-1877,1420],[15,1414],[-857,66],[-142,1321],[-859,1484],[433,1569],[-414,1666],[682,1196],[-824,170],[-389,1816],[420,1944],[942,663],[3914,-1554],[921,988],[1538,391],[1261,2216]],[[714023,712724],[2086,2287]],[[716109,715011],[31,-68]],[[719756,701565],[63,-322]],[[720045,697641],[8,-373]],[[649761,725957],[2308,938],[918,2374],[1397,1168],[1806,-156],[589,1043],[2091,-196],[395,-1341],[3056,-2082],[1055,266],[1350,-1024],[724,-1965],[1390,-1280],[774,-1927],[2162,29],[396,-6060]],[[669733,683211],[-724,-1598]],[[669009,681613],[1319,-2879],[846,-3442],[742,-1452],[2424,-2041],[1,-5639],[1122,13],[384,-758],[-381,-2719],[-2024,-619],[-556,-1209],[-1026,-679],[-559,-2805],[-224,-3357]],[[671077,654027],[-152,-40]],[[634851,682228],[-455,1586],[-1022,1395],[-12,3106],[-920,74],[0,2359],[418,2334],[-1274,3728],[-694,254],[-2067,2741],[-735,168],[92,1611],[-740,2253],[-1340,2139],[113,2632],[668,2271],[1266,1950],[-452,2349],[545,1756],[-1233,96],[-1005,1058],[-918,3026],[-739,3652]],[[624347,724766],[-566,3567],[-972,969],[609,2658],[-1132,6047],[1017,265],[549,2052],[640,-700]],[[633274,682349],[-850,668],[-1551,-795],[-580,-2511],[-1040,-2615]],[[629253,677096],[-486,-193],[-4555,770],[-5163,7712],[-2176,3466],[-4737,5087],[-3399,1099]],[[608737,695037],[283,1342],[-527,842],[-789,5208]],[[607704,702429],[5322,5687],[826,574],[577,2014],[60,3076],[383,2087],[-301,2565],[475,2614],[1033,489],[1584,3030]],[[617663,724565],[1155,1560],[2949,-879],[491,533],[746,-1987],[1343,974]],[[599409,698654],[-656,-2011]],[[598753,696643],[-995,823],[-659,-2213],[688,-2435],[-919,-2092],[1605,489]],[[598473,691215],[-77,-912]],[[598396,690303],[-61,-562]],[[598335,689741],[107,-581],[-830,-4216],[-464,-5130]],[[595125,689609],[645,2231]],[[597523,700720],[841,-48],[1272,2110]],[[599636,702782],[123,-2857],[-350,-1271]],[[538055,780323],[-894,-1532],[707,-500],[-175,-2032],[417,-1460]],[[608737,695037],[-509,-768],[-5566,-2982],[2838,-5874],[-963,-1106],[-456,-1886],[-1984,-764],[-775,-2197],[-1280,-1805],[-2957,966]],[[598473,691215],[301,1695]],[[598774,692910],[-21,3733]],[[599409,698654],[1624,-2062],[1240,-413],[5431,6250]],[[714023,712724],[-783,3269]],[[665127,772295],[-32,-131]],[[665095,772164],[-1906,1936]],[[663189,774100],[33,118]],[[722803,754670],[-801,1322],[-1197,263],[-1010,1885],[-1673,527],[-7596,404],[-428,-701],[-1633,532],[-2329,1990],[-1814,-1407],[-176,-3518],[-2055,1356],[-2601,1092],[-1556,-525],[-860,-2873]],[[697074,755017],[-1475,-1007],[-890,-1529],[-2863,-2687],[-1335,-2907],[46,-1282],[-1536,885],[-311,2294],[-3406,-103],[-586,4833],[-1359,59],[252,5841],[-825,-674],[-853,2568],[-1641,2395],[-1284,-969],[-3434,455],[-3380,-805],[-2304,4008],[-424,1334],[-2646,2687]],[[666820,770413],[22,2100]],[[662809,774782],[-59,-262]],[[662750,774520],[-391,14],[-6872,-3247],[5,-21758]],[[655492,749529],[-1200,-353],[-822,1158],[-960,2731],[-2175,2465],[-2419,-766],[-2100,-2521]],[[636756,779239],[-1728,1359],[-14,1181],[984,52],[-2360,5751],[-2271,-26],[-800,3220],[-954,757],[116,2330],[866,1735],[-590,1592],[528,2877],[929,2493],[1053,619],[2024,-3256],[1136,1095],[-606,3552],[1940,1416],[485,1372],[2080,1221],[1520,2605],[1529,-1504],[2740,1220],[667,-1182],[2131,4],[1954,-2175],[1690,-2696],[214,2002],[2264,-2348],[710,2],[1927,2473],[1445,270],[1196,-1044],[1102,1201],[1445,-166],[1457,-2187],[2580,-666],[396,1287],[2742,-615],[1243,981],[543,2184],[-617,1257],[-2495,1239],[-1109,1928],[1680,1033],[859,1445],[-492,2073],[681,1350],[2574,-171],[112,973],[-2265,1062],[933,1399],[-1543,583],[984,2533],[1653,-609],[3181,941],[3853,1653],[1935,-118],[887,1534],[2071,261],[4085,1215],[3567,3064],[3347,-1346],[1544,845],[1243,-4181],[-257,-2293],[676,-320],[2591,675],[375,-1824],[606,1007],[2935,-1213],[-776,-699],[-77,-2116],[1353,980],[1368,-783],[279,946],[3347,2717],[3279,1993],[-726,-2961],[3135,-3338],[2142,-4388],[2758,-6785],[1437,-4257],[1216,1017],[68,1404],[1193,582],[538,-1853],[1096,-1356],[2856,-73],[2398,1581],[1633,-1302],[1050,-3172],[1851,-1053],[840,-2737],[2470,-594],[1203,654],[1968,-3103]],[[616344,528283],[-1506,-4598],[-1048,-2293],[39,-21831],[1509,-4159],[30,-729]],[[608949,476917],[-3957,6031],[-524,1269],[98,2458],[-9967,11867]],[[594599,498542],[20,51]],[[594373,505981],[-1,1]],[[594372,505982],[1410,4908],[850,1118],[493,2445],[-165,4955],[-1272,4051],[-153,3128],[-633,720],[-525,2413]],[[594377,529720],[3590,7536]],[[704532,738430],[-2755,-373],[-1139,-1057],[-1178,403],[-932,1944],[-2048,-1128],[-348,896],[-3639,-235],[26,2629],[1833,1384],[1346,-906],[1407,1123]],[[697105,743110],[963,285],[1077,-797],[1219,1696],[629,-219],[2098,2277],[-1368,579],[-1267,1718],[-794,126],[-593,2051],[-713,-2400],[-1739,749],[-1671,1830],[1836,2655],[1074,849],[-782,508]],[[785927,574073],[-548,2269],[52,1994],[-711,1444],[-499,5154],[631,271],[1005,3264],[807,1161],[4388,564],[819,-1187],[304,704]],[[792175,589711],[464,-1402],[503,276],[1036,-1373],[612,738],[-406,1742],[1453,1393],[884,-1561],[1943,2313]],[[798664,591837],[-522,-3427],[761,-4081],[-362,-2414],[223,-2905],[-450,-1656],[-651,98],[-635,-1182],[-1435,-765],[-4,-1485],[-1129,357],[-429,-729],[13,-2018],[866,-1671],[-11,-1288],[-1136,1156],[-2035,-611],[-477,-2088],[-1179,-730]],[[851760,728554],[1487,3097],[2416,23],[932,1866]],[[559895,755010],[-1396,-451],[-1371,-1763]],[[555733,756786],[778,1663]],[[556511,758449],[1164,2552],[1743,-3005],[1006,-484],[-529,-2502]],[[634562,673818],[-2142,-58],[-662,2704],[-2505,632]],[[783687,637302],[1263,-2814],[314,-1435],[706,114],[-273,-2461],[704,-2217],[1472,-1153],[1160,1446],[1475,-1745],[-598,-1216],[697,-396],[859,-2112],[-1060,-2414],[-1430,383],[-377,-1986],[2278,-3179],[1196,-903],[-169,-1190],[1035,-1753],[647,-2467],[2253,-4643],[538,-2933],[1945,-2465],[-640,-1425],[1354,-3242],[-480,-1631],[108,-1628]],[[792175,589711],[812,1089],[104,4922],[303,2009],[-600,1703],[-997,1024],[-824,2887],[182,3867],[-1371,3054],[-1037,2981],[-1836,530],[-658,-2251],[-1207,-1156],[-1432,2235],[-2767,-4331],[-546,618],[568,2664],[-174,2213],[655,3377],[-207,3384],[-1629,-287],[-632,1518],[404,1970],[-626,1761],[-543,-410]],[[778117,625082],[353,2451],[876,561],[533,2889],[1062,1510]],[[599933,709876],[1269,-93],[422,-2324],[-1785,-3280],[-203,-1397]],[[468034,545635],[666,1931],[1723,3121],[213,1847],[503,512],[295,1941]],[[569389,635030],[-2,-11809],[-2776,-39],[0,-2958]],[[566611,620224],[-5322,5606],[-6655,7008],[-3992,4205],[-6242,6574],[-2792,-2660]],[[541608,640957],[-2079,-2238],[-2082,3328],[-4203,2001]],[[526440,683810],[1046,935],[892,2346],[-281,4032],[1976,3654],[1885,1973],[-1,4552]],[[579824,326379],[-958,-270],[-1038,-2931],[-737,251],[-1012,1683],[-936,3862],[675,857],[1225,3432],[2472,2123],[1877,-3010],[248,-1066],[-813,-3847],[-1003,-1084]],[[565235,824281],[-87,1206],[-1909,1264]],[[563239,826751],[181,2854],[-734,1307],[-1374,27],[-2324,1188]],[[558988,832127],[1,41]],[[558461,836902],[2884,1994],[4801,-459],[855,-385],[2174,793],[463,-1171],[1433,-416],[2799,-2741]],[[575977,845540],[1236,-1251],[-437,-2793],[1288,-1777],[124,-2387]],[[475879,668566],[-373,0],[63,-3174],[-1718,-191],[-894,-1348],[-1434,0],[-1865,886],[-1305,-753],[152,-1481],[-1056,-3135],[-868,-434],[-1112,-7111],[-1750,-2545],[-694,-2488],[-1278,-1128],[-695,-2251],[-556,-6520],[-1138,-2662],[-583,-2430],[-2528,237],[-3478,-415]],[[452769,631623],[199,2840]],[[463177,667252],[223,1310]],[[578367,773986],[-313,3094],[402,2835],[-479,3122],[-2042,3919],[-989,3053],[-1005,621]],[[573941,790630],[2584,1290],[1693,-1419],[2595,-1556],[186,-3080],[1082,-1236],[63,-1676],[849,-800],[-111,-2835],[-1921,1046],[-608,-609],[57,-2217],[-2043,-3552]],[[187598,692927],[3952,-2631]],[[191550,690296],[7854,31]],[[199404,690327],[7,2665]],[[199411,692992],[4885,-54],[549,-1336]],[[204845,691602],[2722,-4369]],[[207567,687233],[825,-955],[1319,-5737]],[[209711,680541],[1093,-1727]],[[210804,678814],[2369,-2281]],[[213173,676533],[1089,1522]],[[214262,678055],[364,2286]],[[214626,680341],[1292,1347],[1947,-368],[1471,-2067]],[[219336,679253],[1056,-2321]],[[220392,676932],[1008,-4389]],[[221400,672543],[2196,-4617]],[[223596,667926],[136,-2913]],[[223732,665013],[790,-2918]],[[224522,662095],[178,-694],[2848,-2266],[2011,-1149]],[[229559,657986],[590,539]],[[174644,697459],[6675,1079]],[[181319,698538],[-308,-1227]],[[181011,697311],[6587,-4384]],[[559895,755010],[2171,394]],[[511743,618128],[19,-12717],[-314,-3783],[-679,-3570],[-1035,-2363],[-6123,-498],[-945,-1691],[-2063,-447]],[[468362,578206],[-2,3185],[-680,2535],[-546,-320],[-619,1879],[98,3398],[-581,1493],[-145,2076]],[[465887,592452],[488,-377],[644,1480],[313,2550],[847,1184],[1409,-2810],[699,1609],[2098,-290],[2123,725],[10179,1],[424,4660],[-747,1693],[-1105,20579],[-1576,29341],[4920,5]],[[778117,625082],[-645,639],[-1198,-364],[119,-1039],[-1336,-864],[-289,-1593],[-1882,-487],[-623,348],[-550,-1715],[-308,-3129],[133,-1843],[-747,-750],[409,-1208],[446,-3608],[1795,-4180],[109,-1443],[944,-3266],[-627,-771],[-75,-3834],[-1040,-1182],[153,-2307],[900,-2694],[1010,-1837],[564,-1974],[-81,-3633],[825,-3291],[584,-4543],[-1180,-4004],[-1202,-2633],[-152,-2787]],[[553317,762628],[992,-1902],[2202,-2277]],[[553773,755249],[-181,-138]],[[553378,754483],[347,-292]],[[743928,795977],[1051,1713],[2266,126],[1793,1450],[-28,1099],[2809,1892],[4721,3802],[2079,-1541],[3189,-282],[1010,-3156],[1380,-523],[2057,458],[3149,-770],[619,-901],[2486,2057],[489,2697],[-1262,2679],[338,2151],[2628,4555],[2857,-2143],[1521,-174],[2533,-1620],[2029,-588],[492,-4553],[1097,-1172],[2636,-1472],[4864,1984],[2318,-1001],[1370,48],[1450,-1915],[1985,-383],[238,-1960],[1612,-1606],[1731,71],[2675,-974],[1744,-25],[1414,1124],[2064,405],[2710,1136],[302,1073],[3146,2827],[1240,-241],[1475,-1687],[1232,-405],[1158,772],[1524,-1108]],[[591350,345650],[-2148,58]],[[589202,345708],[-146,4865],[-311,359]],[[588745,350932],[104,8869],[-517,3368],[-706,2428],[-716,6400]],[[586910,371997],[3009,6323],[296,3684],[542,1166],[928,3806],[-637,2873],[-169,2291],[768,3807],[-125,9758],[-1958,1562],[-843,118],[-700,1272],[-1254,1129],[-2218,168],[-116,2086]],[[584433,412040],[-456,3868],[1226,1014],[7024,4773]],[[592227,421695],[1207,-3286],[1934,945],[479,-1123],[99,-4142],[-813,-3497],[409,-1846],[2486,-5319],[-342,3180],[531,2368],[1103,605],[382,6911],[-127,1308],[-1666,4587],[-1075,2219]],[[596834,424605],[3,366]],[[597102,435195],[7,928]],[[597109,436123],[1865,-23],[429,765],[1128,-1290],[909,-270],[983,859],[1390,-825],[2232,2558],[876,-797],[842,1092],[1463,630],[1853,1788],[1319,2111]],[[465887,592452],[-1606,2569],[-1936,5341],[-869,24],[-1199,2560],[-1918,573],[-2160,-1137],[-1308,274],[-823,-4105]],[[452643,627982],[233,3099],[10967,28],[-217,6885],[-200,1523],[374,1464],[1143,1606],[1658,1163],[20,14976],[9261,0],[-3,7645]],[[596829,424051],[5,554]],[[592227,421695],[-583,-52],[-889,2440],[821,2283],[150,3522],[1045,834],[-404,2235],[-72,3422],[426,2234],[-329,1566],[1105,1795],[-362,2108],[-604,1165],[-321,2439],[-766,1297]],[[591444,448983],[1391,-1188],[1480,-297]],[[778108,542882],[160,1362],[1714,-1455],[721,-1088],[-199,-2794],[366,-795],[1229,1605],[883,-488],[630,2470]],[[826676,529600],[-81,-174]],[[816815,531927],[28,-1]],[[564946,400206],[2484,945],[1826,-369],[907,-1482]],[[555501,357928],[0,-21769],[-859,-312],[-1416,-2576],[-897,412],[-2044,-15],[-1819,1028],[-173,2044],[-915,1908],[-835,-2494],[-856,-980]],[[541608,640957],[537,-6364],[26,-2362],[1182,-3371],[-56,-1309],[1005,-2549],[-594,-2364],[-724,-17748],[-3074,-6862],[-2554,-8113],[439,-4006]],[[537795,585909],[-785,-200],[-1858,-2039],[-533,-1380],[-2920,1540],[-1258,106],[-2151,-601],[-1580,-2722],[-2403,578],[-1821,2269],[-851,277],[-2033,-2001],[-702,637],[-1161,2938],[-2484,1595],[-695,-685],[-1162,15],[-1878,-1789],[-554,-4045],[-837,-1452],[-142,-4939]],[[537795,585909],[1271,-3709]],[[516367,805398],[-552,132]],[[585749,918146],[-25,-1452],[-1759,565],[-3505,-3625]],[[557283,913723],[-1404,-95],[563,-1581],[-971,-2356],[-4420,1221],[-1283,-3540],[-1645,823],[-3325,-4017],[767,-2197],[-2724,-3348],[169,-1089],[-2613,-1047],[-176,-4904],[-2304,-4266],[1187,-696],[-325,-2666],[-1836,360],[-1770,-796],[-1840,-3844],[606,-1724],[-288,-2421],[525,-1815],[-412,-3346],[2015,-2183],[-549,-1811],[-1080,-260],[818,-3270],[-285,-2038],[-2237,-3048],[-105,-3947],[-707,654]],[[647459,603350],[-990,3862],[-2087,10047]],[[644382,617259],[8332,5923],[1844,11884],[-1265,4160]],[[671509,653647],[-432,380]],[[307320,412833],[-113,108]],[[307956,408551],[363,-118]],[[308196,406812],[33,-40]],[[892036,450086],[-436,607]],[[891592,488108],[0,1148]],[[891592,489256],[365,-10]],[[554456,827357],[1677,-227],[7106,-379]],[[565570,809933],[1390,-3987],[-370,-2577],[-725,-194],[-2951,-4966],[446,-3071],[-753,308]],[[562607,795446],[-2497,2010],[-2844,-120],[-2387,-1110],[-875,2330],[-812,-1171],[-881,656]],[[539607,823035],[636,-341]],[[539863,823801],[-381,71]],[[539476,824343],[6,-471]],[[862574,756716],[445,-1380]],[[642410,650501],[-838,-197],[-449,1276]],[[578367,773986],[1523,-1281],[1786,1099],[840,-947]],[[563069,766802],[-576,2775],[-1124,-973],[-2042,1895],[372,1543],[-1993,2145],[2,1573],[-1481,2125]],[[563546,788777],[905,815],[2709,-1058],[1114,148],[874,-1263],[1585,1143],[1941,484],[1267,1584]],[[558952,831200],[36,927]],[[558054,832260],[-112,-611]],[[0,890205],[0,1449]],[[0,925312],[0,1133]],[[0,927721],[0,818]],[[999999,913406],[0,-23201]],[[866732,772708],[-53,-123]],[[868915,771622],[0,87]],[[606150,783708],[203,2771],[1703,1754],[2321,-62],[617,2513],[-870,1909],[956,1541],[-840,928],[1065,1139],[-109,2350],[-695,-147],[-1683,1682],[-712,-185],[-1833,1349],[-588,-784],[-1733,2911],[-2232,-1198],[-3355,1957],[-277,2988],[-688,945],[-2306,240],[-314,2579],[769,599],[-1841,3344],[-3409,-214],[-625,-1153],[-1443,-78]],[[576786,848429],[98,16]],[[576897,854139],[-40,15]],[[683568,913720],[921,-525]],[[584749,498394],[841,-2938],[45,-4593],[-764,-366]],[[580160,490226],[146,78]],[[581248,494433],[-78,295]],[[582158,496495],[981,-486],[1194,2342],[416,43]],[[452751,631365],[18,258]],[[644382,617259],[-7737,-2221],[-2834,-2751],[-1646,-4198],[-383,-1993],[-1295,-939],[-815,1867],[-1033,-221],[-2510,524],[-718,638],[-2756,-171],[-664,-438],[-1386,1135],[-631,-929],[-72,-3969],[-1016,-1882]],[[594661,560771],[-520,4],[189,2270],[-186,2095],[-2000,3858],[-275,4392],[351,3708],[-1326,34],[41,-1264],[-1846,-18],[731,-1722],[191,-3900],[-1309,-2341],[-772,-2615],[-1195,-2500],[-1348,-335],[-2046,3168],[-1104,-1258],[-368,-1756],[-1315,-939],[-431,-1683],[-2210,15],[-453,1606],[-2254,84],[-1453,-522],[-1833,4011],[-259,1290],[-2031,-751],[-783,-3075],[-703,-5260],[-1069,-1311]],[[563500,569410],[173,2519],[-1017,1924],[-567,5870],[-1464,771],[1119,3194],[-335,2374],[1118,2352],[-357,2507],[804,1019],[726,2604],[5,2198],[475,1004],[2440,460],[-9,22018]],[[594377,529720],[-1352,-2756],[-1367,741],[-1837,-1031],[-499,-1055],[-995,1627],[-883,-724],[-910,623],[-872,-1747]],[[635940,571417],[-2,-10703],[-2666,-8619]],[[562607,795446],[-1130,-3954]],[[549900,856389],[43,-180]],[[589202,345708],[-328,130],[-101,-2893],[-1358,61],[-1128,1086],[-748,2062],[25,2078],[1122,3377],[578,574],[1481,-1251]],[[599701,717503],[725,-490],[616,1999],[726,372],[-276,1323],[337,2045],[2160,-943],[2098,1530],[1597,-1235],[1639,-69],[1833,856],[1915,1610],[2249,-51],[2092,1110],[251,-995]],[[688219,724942],[154,1865],[1332,3234],[108,1214],[-792,2556],[155,1735],[-1186,275],[-908,1384],[1026,2247],[2067,-502],[1288,3553],[967,366],[-191,2183],[577,1366],[830,-831],[2024,2171],[860,-1681],[-1023,-1695],[751,-1495],[847,223]],[[655492,749529],[2891,-348],[-150,3513],[346,518]],[[658579,753212],[-98,-1010]],[[660075,754430],[-26,79]],[[660049,754509],[1142,1924],[1269,-1012],[-929,1844],[1216,891],[2394,-2837],[1131,-26],[290,-2019],[638,-711],[-285,-2577],[1015,-1053],[2427,-157],[467,479],[1128,-1080],[2077,-7318],[4200,-5361],[4028,-4236],[679,178],[2145,-1994],[-298,-3458]],[[844545,449373],[448,506]],[[844993,449879],[685,389]],[[847805,449005],[-394,-641]],[[847005,451738],[291,494]],[[588280,498780],[5,-46]],[[594254,497680],[345,862]],[[597109,436123],[-62,678]],[[591444,448983],[-1352,1488],[-1363,606],[-1133,2019],[-969,612]],[[586627,453708],[-6,44]],[[582337,478120],[15,207]],[[584749,498394],[930,386],[2601,0]],[[594372,505982],[-81,24]],[[588243,499173],[37,-393]],[[582342,501717],[624,1207]],[[582730,503997],[-277,8]],[[586953,517904],[-112,-392]],[[352309,311155],[-25,-42]],[[351267,306014],[29,-49]],[[251173,789100],[-75,-59]],[[280385,761145],[23,13]],[[289481,768262],[-31,38]],[[311664,775792],[-173,-93]],[[312640,774914],[-1,5]],[[138294,830715],[-1003,-1519]],[[663118,773843],[71,257]],[[665095,772164],[-147,-601]],[[666819,770377],[1,36]],[[660049,754509],[-189,580]],[[658593,753357],[-14,-145]],[[662750,774520],[-4,-21]],[[581568,373230],[829,282],[2214,-1082],[1267,227],[1032,-660]],[[586662,453459],[-35,249]],[[584433,412040],[-2524,-318],[-1700,-2010],[-319,-2939]],[[579890,406773],[-220,141]],[[575057,398323],[-669,-492],[-1240,665],[-1306,-134],[-1679,938]],[[584936,456017],[-10,-250]],[[575633,399537],[498,1027]],[[194446,794974],[-11287,0]],[[183159,794974],[-1817,3409],[182,2469],[-384,2214],[-1158,1154],[-2198,3412],[-1879,3917],[-663,-456],[-1133,2619],[-1142,1346],[-1360,18],[-273,1708],[-1344,2838],[-2555,1459],[-771,2631],[2,36479]],[[166666,860191],[12153,0],[5208,0],[10417,0]],[[194444,860191],[2,-65217]],[[146674,804734],[4763,-1918],[2332,-5262],[1798,-1212]],[[155567,796342],[1385,-3802],[-271,-1473]],[[156681,791067],[-4239,2531]],[[152442,793598],[753,1585]],[[153195,795183],[-1696,-517]],[[151499,794666],[-511,1450]],[[150988,796116],[-1665,1521],[-289,1293]],[[149034,798930],[-2506,2827]],[[146528,801757],[-1706,-61]],[[144822,801696],[-116,1881]],[[144706,803577],[1164,-240],[58,1057]],[[145928,804394],[-1647,-501]],[[144281,803893],[-798,1456]],[[143483,805349],[1189,689]],[[144672,806038],[2002,-1304]],[[134017,819872],[828,-2926]],[[134845,816946],[-375,-732]],[[134470,816214],[1025,-2515],[-2622,3729],[60,1281],[-1119,819]],[[131814,819528],[2203,344]],[[142910,818356],[118,-2495]],[[143028,815861],[-470,-1357],[-187,2807]],[[142371,817311],[-427,-530],[-771,2038]],[[141173,818819],[401,1551],[1336,-2014]],[[139308,819707],[-1857,2230]],[[137451,821937],[1590,-639]],[[139041,821298],[267,-1591]],[[131512,825393],[1448,-552]],[[132960,824841],[1295,634]],[[134255,825475],[-953,-5192]],[[133302,820283],[-2316,1437]],[[130986,821720],[-577,1603]],[[130409,823323],[11,2256]],[[130420,825579],[1092,-186]],[[138819,835824],[-202,1310]],[[138617,837134],[-4105,2900]],[[134512,840034],[-691,-52]],[[133821,839982],[-576,2586],[-4971,9944],[-3119,3456]],[[125155,855968],[-298,1719]],[[124857,857687],[-1180,1273],[-2349,-1117]],[[121328,857843],[-713,-2681],[-2389,-1476]],[[118226,853686],[-430,1914]],[[117796,855600],[-4064,4594]],[[113732,860194],[7956,-3],[7932,0],[5287,0],[7932,0],[5287,0],[7932,0]],[[156058,860191],[10608,0]],[[183159,794974],[-5516,0]],[[177643,794974],[-2753,0]],[[174890,794974],[-7396,0]],[[167494,794974],[-8574,0]],[[158920,794974],[-724,0]],[[158196,794974],[-795,2762]],[[157401,797736],[-1545,209]],[[155856,797945],[-35,2883]],[[155821,800828],[-659,-1117]],[[155162,799711],[-1780,1348]],[[153382,801059],[-768,2925]],[[152614,803984],[-3864,212],[1534,798]],[[150284,804994],[-1721,237]],[[148563,805231],[-319,1130]],[[148244,806361],[-1182,-282]],[[147062,806079],[-1807,1681]],[[145255,807760],[176,1939]],[[145431,809699],[-623,1758]],[[144808,811457],[214,3046]],[[145022,814503],[-863,-2968]],[[144159,811535],[-708,2195]],[[143451,813730],[699,4431]],[[143429,817681],[-797,2476],[-1191,732]],[[141441,820889],[-93,1622],[1590,-1307]],[[142938,821204],[-1159,2494]],[[141779,823698],[-903,-2657]],[[140876,821041],[-776,-838],[-2144,2799]],[[137956,823002],[813,2426],[-1076,1704],[2415,6170]],[[140108,833302],[-1178,-614]],[[138930,832688],[-111,3136]],[[252921,841529],[-12978,-18385],[-4259,-5483],[-5,-20456]],[[235679,797205],[-18,-2238],[-5733,8]],[[229928,794975],[-4397,-1],[-7107,0]],[[218424,794974],[-987,22745],[-773,17686],[-2,24786]],[[216662,860191],[11248,0],[8834,3]],[[236744,860194],[-45,-4347]],[[236699,855847],[319,-2357]],[[237018,853490],[1073,-913]],[[238091,852577],[-125,-2480]],[[237966,850097],[767,2741]],[[238733,852838],[2161,-22]],[[240894,852816],[2329,-8777]],[[243223,844039],[-794,-2022]],[[242429,842017],[4484,1823]],[[246913,843840],[1442,-99]],[[248355,843741],[2226,-1441]],[[250581,842300],[2340,-771]],[[322136,777316],[-788,-1048]],[[321348,776268],[-4361,-3630]],[[316987,772638],[-2744,-922]],[[314243,771716],[-701,605]],[[313542,772321],[-966,631]],[[312576,772952],[64,1967],[-976,873]],[[311664,775792],[-17,7865],[-1191,1559],[-1647,-845]],[[308809,784371],[-625,541]],[[308184,784912],[1874,1744],[3,2015],[2125,410],[689,-850],[1835,993]],[[314710,789224],[2375,-660]],[[317085,788564],[508,-1273]],[[317593,787291],[1846,893],[830,-723]],[[320269,787461],[-581,-2111]],[[319688,785350],[-1130,-1585]],[[318558,783765],[1355,-239],[-207,-1024]],[[319706,782502],[1012,-3837],[1737,-441]],[[322455,778224],[-319,-908]],[[345948,810042],[-1590,-1233],[641,-1748],[-2483,-5769]],[[342516,801292],[-356,-2642]],[[342160,798650],[1598,2823]],[[343758,801473],[238,-888]],[[343996,800585],[1754,338]],[[345750,800923],[-1417,-1733]],[[344333,799190],[-131,-1498],[2445,179]],[[346647,797871],[-104,-1672]],[[346543,796199],[2153,1954],[2361,-1232]],[[351057,796921],[128,-1069]],[[351185,795852],[-1633,-2094],[1286,-640]],[[350838,793118],[-1027,-1546]],[[349811,791572],[2807,1423]],[[352618,792995],[-218,-1524],[-1316,-1151]],[[351084,790320],[-700,-2418]],[[350384,787902],[716,-812]],[[351100,787090],[892,1987],[1158,683]],[[353150,789760],[-860,-2725]],[[352290,787035],[147,-1173],[945,1861],[357,-1302]],[[353739,786421],[-1156,-5144],[-1518,-6]],[[351065,781271],[-55,2711]],[[351010,783982],[-1493,-1524]],[[349517,782458],[846,3001]],[[350363,785459],[-896,2801]],[[349467,788260],[-1030,-2871]],[[348437,785389],[-817,58]],[[347620,785447],[-1275,-2839]],[[346345,782608],[-1314,-228],[-138,1210],[964,528]],[[345857,784118],[1728,2431],[-1380,533],[-190,-946],[-1188,171]],[[344827,786307],[12,1712]],[[344839,788019],[-1010,-875]],[[343829,787144],[-2031,-574]],[[341798,786570],[-3835,606]],[[337963,787176],[-2177,-629]],[[335786,786547],[-431,2517]],[[335355,789064],[2616,3120]],[[337971,792184],[-1072,450],[868,2880]],[[337767,795514],[1177,861]],[[338944,796375],[-100,1854],[1994,6850],[1522,3414]],[[342360,808493],[2013,1739],[1575,-190]],[[341388,809491],[-2,3304],[-11295,-1],[-6776,0],[-1264,2706],[1811,1369],[-728,2441],[-1408,-1692],[133,-3724],[-444,-2714],[-2352,2525],[-2496,-269],[-1188,1375],[453,3291],[-2132,-841],[361,3400],[-1442,1614],[-834,2561],[683,1201],[-302,1601],[1306,653],[-798,2486],[2184,-1408],[-342,3138],[3051,-3456],[3708,-111],[1638,-676],[667,3290],[1109,1021],[-2479,4488],[-299,3517],[580,1175],[793,4957],[-1188,351],[-906,2275],[1454,1721],[-619,1078],[1438,533],[-3487,1171],[1153,410],[-143,3137],[-1037,72],[424,2165],[-596,853],[738,1519]],[[320515,861997],[-428,-1741]],[[320087,860256],[1347,308]],[[321434,860564],[2066,-4332]],[[323500,856232],[94,-1290]],[[323594,854942],[1756,-2623]],[[325350,852319],[-1433,-1302],[2172,259]],[[326089,851276],[-1036,-2388]],[[325053,848888],[1374,360]],[[326427,849248],[1631,-1734]],[[328058,847514],[-191,-1478],[-1353,-888]],[[326514,845148],[1677,-478]],[[328191,844670],[-351,-790]],[[327840,843880],[1788,-1407],[-105,-1953]],[[329523,840520],[-1919,107]],[[327604,840627],[1770,-2004]],[[329374,838623],[-68,-2163],[1716,-223]],[[331022,836237],[1364,-1026]],[[332386,835211],[413,-1800]],[[332799,833411],[-1180,-2492]],[[331619,830919],[2384,1477]],[[334003,832396],[2116,-949]],[[336119,831447],[602,-1843]],[[336721,829604],[2272,222]],[[338993,829826],[1550,-1809]],[[340543,828017],[-2075,-1304]],[[338468,826713],[-1338,-1782]],[[337130,824931],[-3305,-1274]],[[333825,823657],[-1407,-3367]],[[332418,820290],[2798,2237]],[[335216,822527],[1861,1980],[2011,744]],[[339088,825251],[-733,738]],[[338355,825989],[2156,-387]],[[340511,825602],[780,-2198]],[[341291,823404],[-1089,-1138],[543,-774]],[[340745,821492],[1363,1602]],[[342108,823094],[2030,-901]],[[344138,822193],[867,-2225]],[[345005,819968],[-117,-4172]],[[344888,815796],[403,-2191]],[[345291,813605],[-3903,-4114]],[[322136,777316],[2050,-1544]],[[324186,775772],[1645,-68],[605,-703]],[[326436,775001],[1465,1460]],[[327901,776461],[1287,-1074]],[[329188,775387],[1280,-2341]],[[330468,773046],[-4118,-2655]],[[326350,770391],[-2201,-1192]],[[324149,769199],[-827,242]],[[323322,769441],[-437,-1166]],[[322885,768275],[-662,938],[-2397,-4603]],[[319826,764610],[-2432,-1819]],[[317394,762791],[-1077,1499]],[[316317,764290],[73,3279]],[[316390,767569],[1610,2165]],[[318000,769734],[-380,163]],[[317620,769897],[3683,3252]],[[321303,773149],[-65,-1013]],[[321238,772136],[2739,1342],[-4291,60]],[[319686,773538],[1662,2730]],[[331391,775898],[539,2552]],[[331930,778450],[1778,-263]],[[333708,778187],[103,-1152]],[[333811,777035],[-1550,-1839]],[[332261,775196],[-2494,-479]],[[329767,774717],[-587,2177],[1736,5067]],[[330916,781961],[1283,1226]],[[332199,783187],[212,-1397],[-681,-3528],[-1467,-1348],[128,-1430],[1000,414]],[[164773,916863],[10,-9267],[10642,-6944],[4258,-2777],[7806,-5092],[5096,-31],[3202,-3521],[1510,-733],[12107,-2039],[7265,-1224],[-7,-25044]],[[216662,860191],[-12497,0],[-9721,0]],[[156058,860191],[-2168,3915],[46,1723],[-2210,-955],[-4198,24],[-508,3401],[-2558,2023],[-1576,2404],[-1967,292],[161,2057],[-1411,1885],[248,1402],[-1401,1233],[803,1241],[-2265,2753],[-1240,2668],[-4151,2518],[686,1260],[-1134,1103],[1576,2209],[-1164,2556],[-2708,-394],[-129,3572],[-1167,2600],[-5751,2],[-120,3019],[-768,1376],[6,6805]],[[120990,912883],[3004,-1178]],[[123994,911705],[-1429,1307],[514,2336]],[[123079,915348],[4976,1286],[-762,-1632]],[[127293,915002],[2808,1073]],[[130101,916075],[898,1283]],[[130999,917358],[4733,1519]],[[135732,918877],[1772,1400]],[[137504,920277],[2361,-862]],[[139865,919415],[-3643,-2167],[-2508,-489]],[[133714,916759],[-4211,-3927]],[[129503,912832],[1868,-425],[-35,1566]],[[131336,913973],[2585,2089],[2153,-19]],[[136074,916043],[119,-1301],[1714,2648],[3455,1339]],[[141362,918729],[384,-1004]],[[141746,917725],[3576,3246],[-853,1857]],[[144469,922828],[2368,-1982]],[[146837,920846],[1462,-3015],[3403,-2258]],[[151702,915573],[516,2842]],[[152218,918415],[2102,1669],[889,-2492]],[[155209,917592],[-837,-1840]],[[154372,915752],[2268,-13]],[[156640,915739],[1622,2564]],[[158262,918303],[4151,-203]],[[162413,918100],[2360,-1237]],[[194439,937095],[5,-17659],[-8004,0],[-11594,6],[552,-2089]],[[175398,917353],[-774,2669]],[[174624,920022],[7063,1258],[5429,-517]],[[187116,920763],[2793,495]],[[189909,921258],[-5902,2263]],[[184007,923521],[-6204,-619]],[[177803,922902],[-4434,256]],[[173369,923158],[-1880,1533],[1249,1601],[5341,1323]],[[178079,927615],[846,975]],[[178925,928590],[-5934,-923]],[[172991,927667],[-54,1592],[-3127,163]],[[169810,929422],[-213,1770]],[[169597,931192],[2032,1642]],[[171629,932834],[-448,1607]],[[171181,934441],[2286,1760]],[[173467,936201],[8093,3209]],[[181560,939410],[1318,-609]],[[182878,938801],[152,-2422]],[[183030,936379],[-969,-1663]],[[182061,934716],[2661,676]],[[184722,935392],[811,1698]],[[185533,937090],[5384,-1584]],[[190917,935506],[-1737,-2119]],[[189180,933387],[4693,1808]],[[193873,935195],[-1266,2056],[1832,-156]],[[167398,943794],[1680,-502]],[[169078,943292],[1633,1284]],[[170711,944576],[2859,-77]],[[173570,944499],[5567,-3631]],[[179137,940868],[177,-1066]],[[179314,939802],[-9763,-4471]],[[169551,935331],[-1240,-1918],[-2144,-875],[-1221,-4189]],[[164946,928349],[-3139,-361]],[[161807,927988],[-3297,-2114],[-2976,3493]],[[155534,929367],[-4875,2725]],[[150659,932092],[2154,2668]],[[152813,934760],[419,2893]],[[153232,937653],[2887,4099]],[[156119,941752],[-2498,3437]],[[153621,945189],[9391,1077]],[[163012,946266],[4869,-1760]],[[167881,944506],[-483,-712]],[[168348,952708],[4561,2933],[-1599,-3156],[-2962,223]],[[194437,952245],[0,-4077]],[[194437,948168],[-6992,-2572]],[[187445,945596],[-2762,79]],[[184683,945675],[-1718,1990]],[[182965,947665],[3601,1240],[3236,261]],[[189802,949166],[1770,1229],[-7438,-938]],[[184134,949457],[-847,2167]],[[183287,951624],[-1209,-2052]],[[182078,949572],[-3547,-710]],[[178531,948862],[-5198,1799]],[[173333,950661],[1238,1192]],[[174571,951853],[2992,119]],[[177563,951972],[2654,1260],[-5287,-617]],[[174930,952615],[1909,1655]],[[176839,954270],[-756,1141],[2859,2156]],[[178942,957567],[3852,83]],[[182794,957650],[1029,-1449]],[[183823,956201],[3128,-30],[4569,-3870]],[[191520,952301],[2917,-56]],[[179024,963052],[-2040,-1550]],[[176984,961502],[181,-2907],[-2593,-1854]],[[174572,956741],[-2406,880]],[[172166,957621],[666,2001]],[[172832,959622],[-2809,-1607]],[[170023,958015],[-1047,-2290],[-986,1266],[-1082,-2852]],[[166908,954139],[-3074,957]],[[163834,955096],[-3836,-451]],[[159998,954645],[97,2708],[2088,238],[7011,5116]],[[169194,962707],[5030,50],[2545,1359]],[[176769,964116],[2255,-1064]],[[183799,965371],[-3325,1261]],[[180474,966632],[1941,652]],[[182415,967284],[1384,-1913]],[[193893,964006],[-4872,-1067]],[[189021,962939],[-3367,1102],[-63,2263]],[[185591,966304],[8843,1052],[-541,-3350]],[[190458,968527],[-4765,716],[7619,2064],[1122,-2577],[-3976,-203]],[[275745,817216],[-3632,1793]],[[272113,819009],[533,807]],[[272646,819816],[1977,116]],[[274623,819932],[1122,-2716]],[[280734,838063],[-2959,-1979]],[[277775,836084],[2024,3959]],[[279799,840043],[935,-1980]],[[310717,863514],[897,-667]],[[311614,862847],[-1153,-1236],[256,1903]],[[319909,868276],[-1665,1681]],[[318244,869957],[1785,75],[-120,-1756]],[[279041,874472],[614,-2284]],[[279655,872188],[-958,-2261],[-1656,1029]],[[277041,870956],[677,3109]],[[277718,874065],[1323,407]],[[304619,875284],[-1192,285]],[[303427,875569],[-1024,1666]],[[302403,877235],[1923,-856]],[[304326,876379],[293,-1095]],[[272221,877686],[-315,-1789]],[[271906,875897],[-2506,-2620]],[[269400,873277],[-1897,-294]],[[267503,872983],[-557,1873]],[[266946,874856],[1453,2538]],[[268399,877394],[3822,292]],[[284615,879658],[-1122,-1024]],[[283493,878634],[-1569,1996]],[[281924,880630],[1751,115],[940,-1087]],[[285098,881443],[642,556],[1267,-1708],[-1909,1152]],[[264112,891353],[853,1103],[7118,-4757]],[[272083,887699],[1039,-2559],[-630,-1073]],[[272492,884067],[2983,348]],[[275475,884415],[1463,-1942]],[[276938,882473],[-2067,-1781]],[[274871,880692],[-3699,1453]],[[271172,882145],[-248,1304]],[[270924,883449],[-2853,1020]],[[268071,884469],[-650,-1693]],[[267421,882776],[-2513,-2986]],[[264908,879790],[-2396,-1008]],[[262512,878782],[-858,3361],[-2896,-777]],[[258758,881366],[-950,574]],[[257808,881940],[2603,2752],[-340,2542]],[[260071,887234],[1146,6745]],[[261217,893979],[1131,1269]],[[262348,895248],[1764,-3895]],[[264792,893213],[-1282,1457]],[[263510,894670],[549,1112]],[[264059,895782],[733,-2569]],[[267428,894527],[-1090,-148]],[[266338,894379],[-802,2127],[1892,-1979]],[[295495,906299],[-2644,265],[-370,1413],[3532,-575]],[[296013,907402],[-518,-1103]],[[259456,906015],[-1011,2159]],[[258445,908174],[1496,493]],[[259941,908667],[-485,-2652]],[[291239,908966],[74,-4127]],[[291313,904839],[-1813,-1504],[-3403,-98]],[[286097,903237],[-836,2600],[1571,3113]],[[286832,908950],[2957,540],[1450,-524]],[[291997,909646],[-1443,1047],[1158,724]],[[291712,911417],[285,-1771]],[[279970,912589],[-542,459]],[[279428,913048],[2248,2652]],[[281676,915700],[1021,-396]],[[282697,915304],[-2727,-2715]],[[286124,914356],[-898,1615]],[[285226,915971],[1591,-74],[-693,-1541]],[[277839,916524],[-2223,991]],[[275616,917515],[3743,655],[-1520,-1646]],[[232499,915545],[1985,-3018]],[[234484,912527],[-2266,-2158]],[[232218,910369],[-2974,431]],[[229244,910800],[-5469,2217]],[[223775,913017],[-41,1263],[2777,1206]],[[226511,915486],[521,2488],[1327,635]],[[228359,918609],[975,-1297],[3165,-1767]],[[164773,916863],[2302,-1330],[2734,-506]],[[169809,915027],[2148,-1269],[5655,-1220],[1190,804]],[[178802,913342],[3380,-1855],[1250,-1543]],[[183432,909944],[-2225,-764],[-1858,-2180]],[[179349,907000],[4868,-1198]],[[184217,905802],[3463,-90]],[[187680,905712],[4529,875],[1952,947]],[[194161,907534],[1310,-1538]],[[195471,905996],[2882,-840]],[[198353,905156],[1843,-2750]],[[200196,902406],[-1574,-204]],[[198622,902202],[2183,-2087],[232,1559]],[[201037,901674],[1306,-719]],[[202343,900955],[-2266,5037]],[[200077,905992],[587,1779],[3713,997]],[[204377,908768],[1785,1931]],[[206162,910699],[-2297,-1002]],[[203865,909697],[-2809,-156]],[[201056,909541],[-318,-933]],[[200738,908608],[-2645,614]],[[198093,909222],[1036,1975]],[[199129,911197],[5969,1833],[1736,-1193]],[[206834,911837],[309,-1542]],[[207143,910295],[3430,-2530]],[[210573,907765],[1999,496]],[[212572,908261],[2172,-1799],[3159,-701]],[[217903,905761],[3052,868]],[[220955,906629],[3637,-687],[2041,495]],[[226633,906437],[2659,-1127]],[[229292,905310],[690,1410]],[[229982,906720],[-2512,285],[-1499,2729]],[[225971,909734],[3444,787],[1904,-2578]],[[231319,907943],[2097,1113]],[[233416,909056],[-1115,-4119]],[[232301,904937],[639,-1671]],[[232940,903266],[1171,265]],[[234111,903531],[1016,-1990]],[[235127,901541],[265,1670]],[[235392,903211],[-1089,2813]],[[234831,907706],[1666,120],[3381,3504]],[[239878,911330],[-2552,1651]],[[237326,912981],[1336,1328]],[[238662,914309],[-525,1891],[-4943,2228]],[[233194,918428],[-1377,2939],[1853,1314]],[[233670,922681],[-1862,1538],[398,2755]],[[232206,926974],[2338,374],[-856,1401]],[[233688,928749],[1864,1958]],[[235552,930707],[1789,446]],[[237341,931153],[4468,-4247]],[[241809,926906],[-91,-2428]],[[241718,924478],[2771,-3358]],[[244489,921120],[20,-1462]],[[244509,919658],[-2531,-2195]],[[241978,917463],[2711,-812],[3769,-158]],[[248458,916493],[-1896,-1297]],[[246562,915196],[2137,-2499]],[[248699,912697],[-293,-2305]],[[248406,910392],[1109,-1212],[1411,4410]],[[250926,913590],[1694,1490],[2821,-2691],[413,-3339],[-1262,237],[419,-3095]],[[255011,906192],[2581,-3447]],[[257592,902745],[2028,1968]],[[259620,904713],[1623,3296]],[[261243,908009],[1207,4132]],[[262450,912141],[1821,1801],[-1457,936]],[[262814,914878],[-78,3659],[3259,-87]],[[265995,918450],[1601,-800]],[[267596,917650],[3586,-343],[-1057,-874]],[[270125,916433],[3962,-2218]],[[274087,914215],[-1748,-1400]],[[272339,912815],[1879,-1341]],[[274218,911474],[-3531,-1249]],[[270687,910225],[1600,-3463]],[[272287,906762],[1962,-2382]],[[274249,904380],[-548,-2310],[-3261,-2858]],[[270440,899212],[-2449,-1296]],[[267991,897916],[-1103,1836],[-2571,2073]],[[264317,901825],[2833,-4376]],[[267150,897449],[-1813,-656]],[[265337,896793],[-2677,2121]],[[262660,898914],[-3309,-35]],[[259351,898879],[1640,-3015],[-3467,-3955],[-1885,-36],[-4943,3479]],[[250696,895352],[-3501,175],[3393,-1356],[2262,-2301]],[[252850,891870],[5407,-890]],[[258257,890980],[-703,-2203]],[[257554,888777],[-2293,-3809]],[[255261,884968],[-1977,-1132]],[[253284,883836],[-3751,-80]],[[249533,883756],[-215,-1995],[-1573,-362],[-2862,691],[3042,-2050]],[[247925,880040],[-86,-2251],[-1864,-992]],[[245975,876797],[-2534,90],[981,-1652]],[[244422,875235],[-1510,36]],[[242912,875271],[66,-2240]],[[242978,873031],[-2928,-1341]],[[240050,871690],[750,-1036]],[[240800,870654],[-2080,-2662]],[[238720,867992],[-21,-1061],[-1607,-4281]],[[237092,862650],[-348,-2456]],[[194439,937095],[3463,-2553],[1513,-4739]],[[199415,929803],[2511,850],[-1081,1509]],[[200845,932162],[-1507,5667]],[[199338,937829],[581,1439]],[[199919,939268],[2995,-430],[3162,-1573]],[[206076,937265],[4064,-9341]],[[210140,927924],[-611,-1954]],[[209529,925970],[1711,-2023]],[[211240,923947],[2511,-638],[4131,-3081],[1444,-143]],[[219326,920085],[299,-2344],[-3609,753]],[[216016,918494],[-1075,-1723]],[[214941,916771],[-2050,794]],[[212891,917565],[747,-2805]],[[213638,914760],[2608,1633],[818,-2747],[-2884,-1187]],[[214180,912459],[-6141,574]],[[208039,913033],[240,953]],[[208279,913986],[-3115,478]],[[205164,914464],[-1441,1644],[-2167,-2591]],[[201556,913517],[-4184,-1435],[-6569,-1291]],[[190803,910791],[-5047,-284]],[[185756,910507],[-1359,2040]],[[184397,912547],[-214,2113]],[[184183,914660],[-4069,413]],[[180114,915073],[-3763,947]],[[176351,916020],[-953,1333]],[[202996,940045],[854,1278]],[[203850,941323],[3059,415],[2400,-896]],[[209309,940842],[183,-1543]],[[209492,939299],[-1963,-2571],[-4533,3317]],[[279063,941080],[3474,67]],[[282537,941147],[3000,-985]],[[285537,940162],[2547,-2480]],[[288084,937682],[-308,-1543],[-3987,452]],[[283789,936591],[-4624,-835]],[[279165,935756],[-1897,2777]],[[277268,938533],[-1780,924]],[[275488,939457],[171,2235],[3404,-612]],[[259474,925417],[4151,836]],[[263625,926253],[624,-889]],[[264249,925364],[584,3462]],[[264833,928826],[-3477,2372]],[[261356,931198],[1405,1353],[2929,-962],[-2749,2185]],[[262941,933774],[-843,2091],[1740,3325],[3434,482]],[[267272,939672],[3118,1852],[3481,-563],[3144,-5266],[-2652,-2571]],[[274363,933124],[1030,-2372],[2268,2861]],[[277661,933613],[3730,-255],[2703,-1014]],[[284094,932344],[-2036,2147],[4349,1056]],[[286407,935547],[4442,-1421]],[[290849,934126],[1086,-2253]],[[291935,931873],[1695,-297]],[[293630,931576],[-308,-2239]],[[293322,929337],[4172,31],[4572,-1872]],[[302066,927496],[-663,-1520],[1951,-12]],[[303354,925964],[381,-1376]],[[303735,924588],[-1724,-2195]],[[302011,922393],[3684,2042]],[[305695,924435],[4421,-1908]],[[310116,922527],[-1557,-1872]],[[308559,920655],[485,-1575],[1732,2213]],[[310776,921293],[2102,-1660]],[[312878,919633],[395,-1800]],[[313273,917833],[-2479,139]],[[310794,917972],[-1108,-1048]],[[309686,916924],[4839,-1425]],[[314525,915499],[-89,-1090]],[[314436,914409],[-3154,565]],[[311282,914974],[518,-1241]],[[311800,913733],[-795,-2890]],[[311005,910843],[3678,-624]],[[314683,910219],[-325,-1362]],[[314358,908857],[1719,384],[-560,-2229],[3991,825]],[[319508,907837],[2280,-2491]],[[321788,905346],[889,-2126]],[[322677,903220],[2211,-173],[-1837,-2444]],[[323051,900603],[4814,1166],[1858,-2196]],[[329723,899573],[-1564,-1989]],[[328159,897584],[-1918,557]],[[326241,898141],[1560,-2201]],[[327801,895940],[-1663,-5]],[[326138,895935],[-191,-2337]],[[325947,893598],[-1885,-137],[-747,-4081]],[[323315,889380],[-803,1074]],[[322512,890454],[-1832,43]],[[320680,890497],[-2351,3836]],[[318329,894333],[1103,1858]],[[319432,896191],[-2282,-478]],[[317150,895713],[-2671,3310]],[[314479,899023],[-1455,-1493],[-1548,1105]],[[311476,898635],[1904,-2700]],[[313380,895935],[-2982,-568],[3163,-2952],[-578,-496]],[[312983,891919],[1832,-2268],[2022,-521]],[[316837,889130],[2400,-2661]],[[319237,886469],[-265,-2421]],[[318972,884048],[1365,0]],[[320337,884048],[456,-4526],[-1882,2963]],[[318911,882485],[396,-3137]],[[319307,879348],[1016,-1969]],[[320323,877379],[-1331,179]],[[318992,877558],[313,-1697]],[[319305,875861],[-3261,2731],[-529,-473]],[[315515,878119],[-2126,1645]],[[313389,879764],[-1982,2540]],[[311407,882304],[474,-1842]],[[311881,880462],[-2142,1793],[-1159,-131]],[[308580,882124],[2138,-3145],[1293,-467]],[[312011,878512],[4710,-5241]],[[316721,873271],[-768,-2018]],[[315953,871253],[-3287,1676]],[[312666,872929],[-2607,498],[-1955,1007]],[[308104,874434],[-1285,2011],[-1920,111]],[[304899,876556],[-2826,1653],[-1998,2296],[1436,487],[-2131,1165]],[[299380,882157],[-3390,4234]],[[295990,886391],[-4091,2183]],[[291899,888574],[767,-1391],[-5788,-1869],[-2965,743]],[[283913,886057],[-1065,1485]],[[282848,887542],[219,1905]],[[283067,889447],[2041,1524]],[[285108,890971],[95,1520]],[[285203,892491],[4162,-1340],[333,525]],[[289698,891676],[5994,1005]],[[295692,892681],[-542,1668],[-1911,2205],[3203,3175],[2947,3433]],[[299389,903162],[-3020,6597]],[[296369,909759],[-937,-434]],[[295432,909325],[-2788,2445],[-574,2124],[-4302,-2212]],[[287768,911682],[-427,1878]],[[287341,913560],[1676,127]],[[289017,913687],[463,1704]],[[289480,915391],[-1725,727]],[[287755,916118],[-292,1438]],[[287463,917556],[-2996,958]],[[284467,918514],[-697,2378]],[[283770,920892],[-1223,-106]],[[282547,920786],[-2176,2217],[-781,-1369],[1272,-2338],[-2018,-491]],[[278844,918805],[-5399,1283]],[[273445,920088],[-1608,-1600]],[[271837,918488],[-2646,964]],[[269191,919452],[-2133,-244]],[[267058,919208],[-6842,1081]],[[260216,920289],[-839,1516]],[[259377,921805],[-3546,-884]],[[255831,920921],[-2632,1606]],[[253199,922527],[-1688,3192]],[[251511,925719],[4475,-695]],[[255986,925024],[1957,398]],[[257943,925422],[-2033,1166],[-3353,471]],[[252557,927059],[-2129,1211]],[[250428,928270],[-498,2703]],[[249930,930973],[453,2745]],[[250383,933718],[1662,3892],[4288,3875]],[[256333,941485],[2641,658]],[[258974,942143],[4608,-153]],[[263582,941990],[-4219,-5554]],[[259363,936436],[1647,-6515]],[[261010,929921],[2814,-2475]],[[263824,927446],[-4350,-2029]],[[224561,941535],[4378,925]],[[228939,942460],[1612,-1311],[-753,-1655]],[[229798,939494],[-3234,-2290]],[[226564,937204],[2670,-48]],[[229234,937156],[880,-2070]],[[230114,935086],[1713,331]],[[231827,935417],[-705,-2280]],[[231122,933137],[507,-2844]],[[231629,930293],[-2691,-1209]],[[228938,929084],[-2435,850]],[[226503,929934],[723,-1969]],[[227226,927965],[-2690,-437],[-3965,4651]],[[220571,932179],[-3137,964],[-2750,2773]],[[214684,935916],[2198,1623]],[[216882,937539],[1588,-1840]],[[218470,935699],[2405,158]],[[220875,935857],[1078,1127]],[[221953,936984],[-951,1726]],[[221002,938710],[-2810,1045]],[[218192,939755],[1488,2218]],[[219680,941973],[2536,833]],[[223930,942411],[4686,1359],[-1589,-1423],[-3097,64]],[[241192,944080],[2634,-1117]],[[243826,942963],[5189,-616]],[[249015,942347],[-4898,-6604],[-5815,19]],[[238302,935762],[1822,-1989]],[[240124,933773],[-1340,-2325]],[[238784,931448],[-3209,-8]],[[235575,931440],[-985,4468]],[[234590,935908],[-237,5414]],[[234353,941322],[2598,-189]],[[236951,941133],[-951,2135]],[[236000,943268],[5192,812]],[[210778,949266],[-2132,660]],[[208646,949926],[1503,1671]],[[210149,951597],[1956,-1581]],[[212105,950016],[-1327,-750]],[[240159,949216],[-12,-1995]],[[240147,947221],[-3196,-291]],[[236951,946930],[-5173,2063],[2469,3190]],[[234247,952183],[3455,383]],[[237702,952566],[2457,-3350]],[[216034,955063],[-3020,-1485],[-2878,2478]],[[210136,956056],[4908,588]],[[215044,956644],[990,-1581]],[[211047,958429],[2699,-788]],[[213746,957641],[-3387,-733]],[[210359,956908],[688,1521]],[[228608,957739],[1014,-6202]],[[229622,951537],[-940,-1733]],[[228682,949804],[-2484,-698]],[[226198,949106],[-4627,-9]],[[221571,949097],[-1380,2007]],[[220191,951104],[3167,1830]],[[223358,952934],[-8195,-840]],[[215163,952094],[1662,2193]],[[216825,954287],[235,3289]],[[217060,957576],[5106,-2959]],[[222166,954617],[-2419,3175]],[[219747,957792],[6056,1294]],[[225803,959086],[2805,-1347]],[[194437,952245],[2939,947],[-2365,972]],[[195011,954164],[1016,1458]],[[196027,955622],[-1592,2195],[1847,1661]],[[196282,959478],[2420,-133],[-126,-1769]],[[198576,957576],[1834,-2259]],[[200410,955317],[-468,-1498]],[[199942,953819],[3136,-133]],[[203078,953686],[1376,1646]],[[204454,955332],[2543,-1863]],[[206997,953469],[-1060,-3283]],[[205937,950186],[-3585,-1567],[-4661,816]],[[197691,949435],[-3254,-1267]],[[238068,960381],[3611,-1730]],[[241679,958651],[4696,357],[5273,-2912]],[[251648,956096],[-5202,-173]],[[246446,955923],[5762,-2358],[-1225,-1167],[2026,-658]],[[253009,951740],[4609,970],[3302,-684]],[[260920,952026],[5935,1877]],[[266855,953903],[4940,71]],[[271795,953974],[5088,-1196]],[[276883,952778],[1910,-2546]],[[278793,950232],[-2009,-876],[2656,-793]],[[279440,948563],[-2434,-1991]],[[277006,946572],[-4253,-622]],[[272753,945950],[-9035,772]],[[263718,946722],[-2484,-641],[-6854,-27]],[[254380,946054],[231,1722]],[[254611,947776],[-4179,-1400],[-5881,1450]],[[244551,947826],[-1293,3277]],[[243258,951103],[995,1844],[-2842,4126],[-6062,-532]],[[235349,956541],[-4365,2738]],[[230984,959279],[243,1454]],[[231227,960733],[3111,545]],[[234338,961278],[3730,-897]],[[250463,962485],[-3651,710],[-5,1307]],[[246807,964502],[2715,-79]],[[249522,964423],[941,-1938]],[[209605,962901],[-1869,-923],[-2364,3220],[4233,-2297]],[[234765,965592],[5282,-126]],[[240047,965466],[176,-1756],[-6854,57]],[[233369,963767],[1396,1825]],[[232764,969972],[3660,-1103],[-556,-2089],[-5284,-1106]],[[230584,965674],[-3516,3693]],[[227068,969367],[120,2224]],[[227188,971591],[5576,-1619]],[[215066,972034],[2424,1183],[5817,-2939],[-394,-1659]],[[222913,968619],[1625,-2642]],[[224538,965977],[-3944,205]],[[220594,966182],[-1356,1790],[-4603,1051]],[[214635,969023],[-4425,-603],[-1865,1476]],[[208345,969896],[3420,6]],[[211765,969902],[-1076,2786]],[[210689,972688],[-1849,-1082]],[[208840,971606],[-1995,1336],[411,1724]],[[207256,974666],[5449,-48],[2361,-2584]],[[225579,978561],[-137,-1446],[-3477,1076]],[[221965,978191],[1003,1336],[2611,-966]],[[244762,985385],[3451,-3194]],[[248213,982191],[8245,-1313]],[[256458,980878],[-687,-1627]],[[255771,979251],[2624,-1206],[-882,-1859]],[[257513,976186],[4576,185]],[[262089,976371],[995,-2388]],[[263084,973983],[-6467,-3153]],[[256617,970830],[-3122,-546]],[[253495,970284],[-137,-2320]],[[253358,967964],[-3461,2456]],[[249897,970420],[1472,-2391]],[[251369,968029],[-6645,199]],[[244724,968228],[-6288,4486]],[[238436,972714],[7831,2173]],[[246267,974887],[-4106,527]],[[242161,975414],[-6338,-948]],[[235823,974466],[-4639,5012]],[[231184,979478],[5911,-516],[-4785,1448]],[[232310,980410],[2276,435]],[[234586,980845],[-1622,1924],[6125,-783]],[[239089,981986],[-4409,1652]],[[234680,983638],[680,965],[7938,1643]],[[243298,986246],[1464,-861]],[[306975,996546],[8048,-431]],[[315023,996115],[-2237,-1635]],[[312786,994480],[6923,1379],[5054,-1988]],[[324763,993871],[4467,-581]],[[329230,993290],[-1943,-2511]],[[327287,990779],[-6660,-1835],[-5699,-694]],[[314928,988250],[-4700,-2105]],[[317401,987526],[699,-1242]],[[318100,986284],[-8741,-3591]],[[309359,982693],[-7659,-5433],[-5791,-30],[18,-1548]],[[295927,975682],[-4981,-439]],[[290946,975243],[-4554,541]],[[286392,975784],[6574,-2724],[-11249,133]],[[281717,973193],[1942,-786]],[[283659,972407],[4519,382]],[[288178,972789],[5063,-1675]],[[293241,971114],[-1237,-1062]],[[292004,970052],[-4271,-198],[3396,-1088]],[[291129,968766],[-1868,-1884],[-5963,-377]],[[283298,966505],[-177,-2530]],[[283121,963975],[-2203,-1105]],[[280918,962870],[-6244,-373]],[[274674,962497],[4934,-659]],[[279608,961838],[334,-1317]],[[279942,960521],[2589,247]],[[282531,960768],[12,-2409],[-6683,-2338]],[[275860,956021],[-1334,1992]],[[274526,958013],[-3776,1247],[824,-1525]],[[271574,957735],[-4590,-75]],[[266984,957660],[-3488,-880]],[[263496,956780],[-2707,772]],[[260789,957552],[-6334,-177]],[[254455,957375],[-3261,515]],[[251194,957890],[196,1984],[3059,1641]],[[254449,961515],[4294,418],[-3452,3228]],[[255291,965161],[2992,1025]],[[258283,966186],[3971,-2555]],[[262254,963631],[2361,-592]],[[264615,963039],[2664,1016]],[[267279,964055],[-4194,157]],[[263085,964212],[-717,2184]],[[262368,966396],[1453,2279]],[[263821,968675],[-3315,-1370]],[[260506,967305],[827,1550]],[[261333,968855],[-4533,-984],[2067,3540]],[[258867,971411],[5011,818]],[[263878,972229],[4812,-841]],[[268690,971388],[2313,790]],[[271003,972178],[-3159,889],[-4205,3308],[-3697,1381]],[[259942,977756],[316,2809]],[[260258,980565],[7176,-537],[5189,-2999]],[[272623,977029],[2348,-174],[-5419,3465]],[[269552,980320],[8084,1485]],[[277636,981805],[8891,2071]],[[286527,983876],[-4725,256],[735,1458],[-7557,-3037]],[[274980,982553],[-8526,-584],[-9037,672]],[[257417,982641],[-4421,805]],[[252996,983446],[1412,1150]],[[254408,984596],[-4262,1024],[4079,2323]],[[254225,987943],[-8122,80]],[[246103,988023],[2535,1771]],[[248638,989794],[7920,1232]],[[256558,991026],[7206,-606]],[[263764,990420],[-4267,1212],[4678,1552]],[[264175,993184],[15200,-3524]],[[279375,989660],[-8407,3393]],[[270968,993053],[4003,2085],[6282,-591]],[[281253,994547],[-3906,1373]],[[277347,995920],[20398,1007],[9230,-381]],[[269941,777103],[157,-702],[2294,718]],[[272392,777119],[160,-2629]],[[272552,774490],[-3724,2172]],[[268828,776662],[1113,441]],[[279110,806663],[-539,898]],[[278571,807561],[536,2530]],[[279107,810091],[3,-3428]],[[279112,806382],[18,-19952],[412,-3135],[1622,-3722],[3051,-1226],[1248,-1677],[1138,-142],[1139,-2271],[1415,-451],[2888,1315],[1299,-440],[156,-2093]],[[293498,772588],[-1023,-1248]],[[292475,771340],[-1307,-619]],[[291168,770721],[-1718,-2421]],[[289450,768300],[-720,-735]],[[288730,767565],[-2939,-1148],[656,-1362]],[[286447,765055],[-914,-398]],[[285533,764657],[-1043,963]],[[284490,765620],[-4103,-1205]],[[280387,764415],[-1412,-1521],[-570,-1532]],[[278405,761362],[1213,-722]],[[279618,760640],[767,504]],[[280385,761144],[-13,-1048]],[[280372,760096],[25,-1444]],[[280397,758652],[-3232,-520]],[[277165,758132],[-641,-954],[-2648,99],[-1284,-2203],[-2162,-1310],[-1286,200],[88,1212]],[[269232,755176],[1352,232]],[[270584,755408],[-154,1442]],[[270430,756850],[633,2721]],[[271063,759571],[1757,1834],[213,4762]],[[273033,766167],[1195,3039],[-1165,3598],[1113,-13]],[[274176,772791],[103,-1306]],[[274279,771485],[1011,-1465]],[[275290,770020],[1860,-1609]],[[277150,768411],[327,1762]],[[277477,770173],[1085,-270],[-1023,2062]],[[277539,771965],[124,1381]],[[277663,773346],[-857,337]],[[276806,773683],[-1300,3268],[-2164,93]],[[273342,777044],[-52,905]],[[273290,777949],[-3917,366],[-2962,1065]],[[266411,779380],[-204,1195]],[[266207,780575],[-931,-470]],[[265276,780105],[321,2503]],[[265597,782608],[-1074,776]],[[264523,783384],[493,1680],[-1167,1886],[443,1844]],[[264292,788794],[-2563,120],[-908,1422],[-811,3086]],[[260010,793422],[-2298,265]],[[257712,793687],[-2889,1172]],[[254823,794859],[85,-2231]],[[254908,792628],[-749,1398]],[[254159,794026],[-755,-1481]],[[253404,792545],[-1184,-783]],[[252220,791762],[-1047,-2662]],[[251173,789100],[-3508,1179]],[[247665,790279],[-1884,-843]],[[245781,789436],[-4105,3279]],[[241676,792715],[-1975,-511]],[[239701,792204],[-2537,1286]],[[237164,793490],[-650,3328],[-835,387]],[[252921,841529],[2426,-2274]],[[255347,839255],[1427,-2435]],[[256774,836820],[5235,-2697]],[[262009,834123],[1710,-1869]],[[263719,832254],[3196,172]],[[266915,832426],[3703,-983]],[[270618,831443],[994,-1986],[-551,-2711],[768,-3189]],[[271829,823557],[-331,-5075]],[[271498,818482],[1914,-3518]],[[273412,814964],[61,-774]],[[273473,814190],[2477,-2833]],[[275950,811357],[499,-2673],[1041,-144]],[[277490,808540],[1622,-2158]],[[323107,780571],[1533,-828]],[[324640,779743],[2683,385]],[[327323,780128],[388,-389],[-2373,-2489]],[[325338,777250],[-485,1590]],[[324853,778840],[-622,-690]],[[324231,778150],[-1339,1447],[-978,164]],[[321914,779761],[-770,1278]],[[321144,781039],[1096,2492]],[[322240,783531],[-262,-1695]],[[321978,781836],[1129,-1265]],[[295648,774097],[-1094,-164]],[[294554,773933],[1345,1559],[-251,-1395]],[[308184,784912],[-526,997]],[[307658,785909],[-2124,-4467]],[[305534,781442],[-802,-4757],[-1671,-3813]],[[303061,772872],[-518,187]],[[302543,773059],[-675,-23]],[[301868,773036],[-528,-1674]],[[301340,771362],[-5096,-12]],[[296244,771350],[-3769,-10]],[[292475,771340],[3197,2496]],[[295672,773836],[1107,3465]],[[296779,777301],[3496,3684],[1777,737]],[[302052,781722],[2060,1637]],[[304112,783359],[4257,7361]],[[308369,790720],[3962,3441]],[[312331,794161],[3840,2116],[1819,315]],[[317990,796592],[1909,-441]],[[319899,796151],[1596,-1599]],[[321495,794552],[-242,-2954]],[[321253,791598],[-2530,-2381],[-1853,993]],[[316870,790210],[-2160,-986]],[[327168,795484],[-3740,1896]],[[323428,797380],[-886,1532]],[[322542,798912],[-1543,1007],[858,675]],[[321857,800594],[3536,-1400],[2892,-2499]],[[328285,796695],[45,-1124]],[[341388,809491],[-3917,-879]],[[337471,808612],[-1820,-3052]],[[335651,805560],[-2541,-3112]],[[333110,802448],[-3360,-312]],[[328542,801556],[-541,763]],[[328001,802319],[-2211,408]],[[325790,802727],[-5979,-155]],[[319811,802572],[-1113,263]],[[318698,802835],[-3408,-641]],[[315290,802194],[-1238,-1291],[-1197,-3823]],[[312855,797080],[-1901,-543],[-2424,-2535]],[[308530,794002],[-2069,-3731]],[[306461,790271],[-2297,918],[2016,-1517]],[[306180,789672],[-362,-1575]],[[305818,788097],[-2223,-4104],[-1561,-2036]],[[302034,781957],[-1700,-646]],[[300334,781311],[-3059,-2827]],[[297275,778484],[-1377,-2793]],[[295898,775691],[-1559,-1400]],[[294339,774291],[178,-929]],[[294517,773362],[-1019,-774]],[[279112,806382],[-2,281]],[[279107,810091],[1336,-479]],[[280443,809612],[381,-1561]],[[280824,808051],[477,1760],[-684,1399]],[[280617,811210],[1350,3072]],[[281967,814282],[-644,2225]],[[281323,816507],[-1082,6455]],[[280241,822962],[469,729]],[[280710,823691],[-2003,5078],[2101,1090],[2827,2104],[1573,1889]],[[285208,833852],[1874,3270]],[[287082,837122],[363,3553]],[[287445,840675],[-148,2809]],[[287297,843484],[-1622,4963]],[[285675,848447],[-3773,3931]],[[281902,852378],[157,1131]],[[282059,853509],[1939,3002]],[[283998,856511],[96,1753]],[[284094,858264],[1096,715]],[[285190,858979],[55,1456]],[[285245,860435],[-1331,3540]],[[283914,863975],[522,1098]],[[284436,865073],[-1607,-36]],[[282829,865037],[1853,4366]],[[284682,869403],[-1203,1219]],[[283479,870622],[-335,3516]],[[283144,874138],[1932,1287]],[[285076,875425],[4321,-1521]],[[289397,873904],[3253,-620]],[[292650,873284],[2822,1440]],[[295472,874724],[3900,-3689]],[[299372,871035],[2231,-3984],[3177,-536],[510,-1116],[1732,775],[-927,-6316]],[[306095,859858],[628,-1598],[-284,-1976]],[[307222,856261],[-366,-2776]],[[306856,853485],[2936,-271]],[[309792,853214],[669,-2514]],[[310461,850700],[1845,-1100]],[[312306,849600],[2672,1987]],[[314978,851587],[2782,3329]],[[317760,854916],[556,4055]],[[318316,858971],[1319,2706]],[[319635,861677],[880,320]],[[218424,794974],[-7407,0]],[[211017,794974],[-9175,0],[-7396,0]],[[113732,860194],[-65,2027],[-2483,-952]],[[111184,861269],[-2857,694]],[[108327,861963],[0,55397]],[[108327,917360],[5057,-801],[2926,-2154]],[[116310,914405],[4680,-1522]],[[6513,810872],[1645,1335],[-238,-1097],[-1407,-238]],[[9463,811999],[432,-667]],[[9895,811332],[-1456,-891]],[[8439,810441],[1024,1558]],[[13068,812920],[2748,1149]],[[15816,814069],[-1032,-1074],[-1716,-75]],[[33432,820758],[-3061,-3029]],[[30371,817729],[1969,3695]],[[32340,821424],[1032,595]],[[33372,822019],[60,-1261]],[[34659,820350],[-795,281],[1868,1201]],[[35732,821832],[440,2586]],[[36172,824418],[2075,-180],[-1499,-2705],[-2089,-1183]],[[45900,830448],[327,-1453]],[[46227,828995],[-4071,-1875]],[[42156,827120],[-178,1117],[994,1619]],[[42972,829856],[1839,938]],[[44811,830794],[1089,-346]],[[129708,833783],[-959,-1626]],[[128749,832157],[44,1600],[915,26]],[[136169,833460],[-581,-1676]],[[135588,831784],[-721,1199]],[[134867,832983],[-875,-1440],[-231,1485]],[[133761,833028],[614,2461]],[[135363,836221],[806,-2761]],[[128983,838496],[1009,-115]],[[129992,838381],[2860,-4972]],[[132852,833409],[-1162,-96]],[[131690,833313],[1708,-1515],[-438,-2939]],[[132960,828859],[-1800,1990]],[[131160,830849],[-892,1846]],[[130268,832695],[193,1360],[-1795,1158]],[[128666,835213],[317,3283]],[[132963,836150],[-1464,799]],[[131499,836949],[779,2491]],[[132278,839440],[685,-3290]],[[127916,837243],[-665,-301]],[[127251,836942],[-512,4513]],[[126739,841455],[1067,36],[374,-1556],[-264,-2692]],[[129538,842431],[1145,-729]],[[130683,841702],[-719,-2463]],[[129964,839239],[-1084,-3]],[[128880,839236],[-1046,3232]],[[127834,842468],[1704,-37]],[[125084,844493],[969,-3752]],[[126053,840741],[-93,-2907]],[[125960,837834],[-614,2624],[-1265,897],[363,1217]],[[124444,842572],[-838,1283],[-863,-1389]],[[122743,842466],[69,1825]],[[122812,844291],[1226,1279],[1046,-1077]],[[75283,847292],[1304,11]],[[76587,847303],[590,-1474]],[[77177,845829],[-2940,-2078]],[[74237,843751],[-1341,-2179],[-1352,1686]],[[71544,843258],[-47,-1370]],[[71497,841888],[-1237,2510]],[[70260,844398],[475,1327]],[[70735,845725],[1404,422]],[[72139,846147],[349,1121]],[[72488,847268],[1156,-527],[910,1427]],[[74554,848168],[729,-876]],[[123296,848287],[1697,351]],[[124993,848638],[197,-3377]],[[125190,845261],[-1573,1074],[-541,-1436],[-2434,3271],[684,1462],[1644,151],[326,-1496]],[[125887,849293],[1223,-104],[-74,-1538]],[[127036,847651],[948,-3245]],[[127984,844406],[-1851,-1451],[292,2312],[-1240,5017],[702,-991]],[[76620,850469],[853,-1179],[-1866,-861]],[[75607,848429],[-1668,423],[1502,1950]],[[75441,850802],[1179,-333]],[[89622,859078],[1542,3229]],[[91164,862307],[539,-616]],[[91703,861691],[-2081,-2613]],[[38512,862457],[1126,-411],[385,-2376]],[[40023,859670],[-1431,-816],[-2868,1381]],[[35724,860235],[-825,1173]],[[34899,861408],[1666,62]],[[36565,861470],[1947,987]],[[23714,881749],[2868,349]],[[26582,882098],[2784,-2077],[1977,-223]],[[31343,879798],[-377,-826]],[[30966,878972],[-2572,-459]],[[28394,878513],[-2080,1691]],[[26314,880204],[-3512,269]],[[22802,880473],[912,1276]],[[138819,835824],[-33,-3497]],[[138786,832327],[-1496,-3131],[-762,226],[-550,2074]],[[135978,831496],[591,1033]],[[136569,832529],[-848,3942]],[[135721,836471],[-1788,-716]],[[133933,835755],[-554,-2023]],[[133379,833732],[-667,1102],[1054,2601]],[[133766,837435],[-2970,5257]],[[129261,843431],[-245,3098]],[[129016,846529],[-1819,3187],[-1264,899]],[[125933,850615],[-1300,2714],[-645,3415],[-384,-1286],[1258,-5305]],[[124862,850153],[-2289,517],[-759,2339]],[[121814,853009],[-2340,1455]],[[119474,854464],[2577,-3447],[-1447,-1230]],[[120604,849787],[-2671,1992]],[[117933,851779],[-2246,2998]],[[115687,854777],[-4019,2719]],[[111668,857496],[1302,1960],[-524,830],[-1937,-1721]],[[110509,858565],[-1741,131]],[[108768,858696],[-2296,1310]],[[106472,860006],[-3544,753]],[[102928,860759],[-3338,-478],[-2094,1888]],[[97496,862169],[584,1979]],[[98080,864148],[-1548,-1711],[-1808,580]],[[94724,863017],[-2054,2483]],[[92670,865500],[155,1366]],[[92825,866866],[-2245,-1243],[-1707,299],[705,1484]],[[89578,867406],[-2239,-2466],[1649,-1883]],[[88988,863057],[-1297,-2937]],[[87691,860120],[-2679,691]],[[85012,860811],[-563,-1987]],[[84449,858824],[-2732,-1219]],[[81717,857605],[-980,-1869]],[[80737,855736],[-2234,-359],[-309,1290],[1251,652],[-1261,1574]],[[78184,858893],[1116,2491]],[[79300,861384],[265,3084],[2541,1780],[2359,-176]],[[84465,866072],[-980,1780],[-1853,41],[-3116,-2313]],[[78516,865580],[-46,-924],[-2510,-3060]],[[75960,861596],[-292,-1880]],[[75668,859716],[-1255,-464]],[[74413,859252],[-2436,-2840]],[[71977,856412],[-250,-1231]],[[71727,855181],[2343,-1763]],[[74070,853418],[-1904,-2162]],[[72166,851256],[-630,-1976]],[[71536,849280],[-2112,-851],[-2141,-2652]],[[67283,845777],[-1945,-1424]],[[65338,844353],[-420,-1884]],[[64918,842469],[-3444,-2160],[-1857,-1836]],[[59617,838473],[-700,-2064]],[[58917,836409],[-2649,-848]],[[56268,835561],[-2100,-1816],[-2726,-826]],[[51442,832919],[60,1370]],[[51502,834289],[-1708,-2902]],[[49794,831387],[-1300,613],[-368,-1458],[-1835,-933]],[[46291,829609],[156,1675]],[[46447,831284],[1035,437]],[[47482,831721],[2081,3103]],[[49563,834824],[2616,1788]],[[52179,836612],[2565,-1280]],[[54744,835332],[-655,948],[627,2067]],[[54716,838347],[3644,3235]],[[58360,841582],[3480,4076]],[[61840,845658],[594,5174]],[[62434,850832],[1060,2703]],[[63494,853535],[-2444,-1407]],[[61050,852128],[-2094,1554]],[[58956,853682],[-36,-2735]],[[58920,850947],[-817,171],[-1633,2615]],[[56470,853733],[-694,-540]],[[55776,853193],[-1229,1370]],[[54547,854563],[-2774,-2261]],[[51773,852302],[-2177,-150]],[[49596,852152],[1169,889],[-831,2901]],[[49934,855942],[541,1805],[-1646,4120]],[[48829,861867],[786,2379],[-1519,-2469],[317,-1654],[-3710,-1084],[-2099,2945],[-1921,1407],[1525,2078]],[[42208,865469],[2985,-1789]],[[45193,863680],[860,991]],[[46053,864671],[-4875,1234]],[[41178,865905],[833,718]],[[42011,866623],[-2265,1263],[-1324,2078]],[[38422,869964],[1541,1295]],[[39963,871259],[-262,1369]],[[39701,872628],[1425,2210],[1153,46]],[[42279,874884],[-183,1894]],[[42096,876778],[2050,2730],[2079,-1279]],[[46225,878229],[2048,1303],[940,1561]],[[49213,881093],[3286,170],[893,1546]],[[53392,882809],[-581,2562]],[[52811,885371],[-1186,1629]],[[51625,887000],[1341,313]],[[52966,887313],[-708,2043],[-1590,-638]],[[50668,888718],[-2910,-2619]],[[47758,886099],[-2518,1268]],[[45240,887367],[-3294,-756]],[[41946,886611],[-3455,724]],[[38491,887335],[-757,2036]],[[37734,889371],[-1426,1365],[2032,880]],[[38340,891616],[-3353,690],[-1899,1397]],[[33088,893703],[2816,1300]],[[35904,895003],[4514,3156]],[[40418,898159],[4783,1224]],[[45201,899383],[-850,-2375]],[[44351,897008],[938,-782],[5221,-177]],[[50510,896049],[1001,1348],[-1034,531]],[[50477,897928],[-2165,3101]],[[48312,901029],[1639,-653]],[[49951,900376],[300,-1330]],[[50251,899046],[3496,-1106]],[[53747,897940],[1079,1182]],[[54826,899122],[-1671,583],[-1484,-705]],[[51671,899000],[-1273,880]],[[50398,899880],[651,1653]],[[51049,901533],[-3832,284]],[[47217,901817],[-1997,997]],[[45220,902814],[-1125,2436],[-3501,2600]],[[40594,907850],[-3890,1860],[1128,388],[476,2726]],[[38308,912824],[5295,304],[3169,2674],[582,2193],[2440,2392],[2994,846]],[[52788,921233],[4671,3400],[3655,-197],[4246,3333],[6320,-3594]],[[71680,924175],[2673,778],[2778,-723]],[[77131,924230],[-661,-929]],[[76470,923301],[3461,-1391]],[[79931,921910],[5430,486],[4345,-1681],[5228,-339],[1740,-896]],[[96674,919480],[5497,637],[5029,-2743]],[[107200,917374],[1127,-14]],[[256972,684688],[-834,-509]],[[256138,684179],[-614,2384]],[[255524,686563],[-345,-1940]],[[255179,684623],[-734,25]],[[254445,684648],[-241,9003],[1116,18025],[-246,391]],[[255074,712067],[-48,154],[7130,-143]],[[262156,712078],[1175,-12113],[753,-4205],[-540,-2169],[324,-5206]],[[263868,688385],[-7184,-6],[478,-1950],[-190,-1741]],[[250820,718007],[-663,-1911],[-553,-3346],[-420,-671]],[[249184,712079],[-949,-3479],[-1208,-2604],[-383,-1769],[162,-3909]],[[246806,700318],[-8032,-23]],[[238774,700295],[-17,3214],[-1213,556]],[[237544,704065],[125,10302],[-498,6598]],[[237171,720965],[6963,0],[5415,0],[240,-1210],[-848,-1801],[1879,53]],[[197092,723926],[-3,-33609]],[[197089,690317],[-5539,-21]],[[191550,690296],[-3952,2631]],[[187598,692927],[-6587,4384]],[[181011,697311],[308,1227]],[[181319,698538],[688,750],[-632,1942],[544,4349],[1065,2267],[-681,1197],[-666,2977]],[[181637,712020],[55,2143],[-347,4338],[1868,573],[8,4872]],[[183221,723946],[5202,-7],[8669,-13]],[[154920,753549],[5885,-2],[5859,0]],[[166664,753547],[2,-17775],[5205,-7749],[5485,-8879],[4281,-7124]],[[181319,698538],[-6676,-1079],[-942,4515],[-1702,2528],[-917,129]],[[171082,704631],[-267,1621],[-1770,560]],[[169045,706812],[-1284,1813]],[[167761,708625],[-2431,318]],[[165330,708943],[-454,642]],[[164876,709585],[31,2941]],[[164907,712526],[-630,1712]],[[164277,714238],[-2826,5721],[-9,3601]],[[161442,723560],[-1429,1591]],[[160013,725151],[-331,3345],[1233,-1741]],[[160915,726755],[-875,2857],[1857,436]],[[161897,730048],[-2131,443],[-103,-1673]],[[159663,728818],[-1141,1356],[-525,2334]],[[157997,732508],[-1611,2714],[-511,5649],[-1220,2317]],[[154655,743188],[-132,1417]],[[154523,744605],[663,2836]],[[155186,747441],[179,2455]],[[155365,749896],[-445,3653]],[[216598,741701],[33,-17775]],[[216631,723926],[-2745,0]],[[213886,723926],[-5248,0],[-7347,0],[-4199,0]],[[197092,723926],[1,23698]],[[197093,747624],[8724,0],[5234,0]],[[211051,747624],[5546,-1],[1,-5922]],[[300553,753615],[-115,-4008]],[[300438,749607],[-3007,-298]],[[297431,749309],[-1960,-1738]],[[295471,747571],[416,6302]],[[295887,753873],[4666,-258]],[[290497,740602],[-463,-1035]],[[290034,739567],[543,-3247]],[[290577,736320],[985,-3774]],[[291562,732546],[-1860,-4],[-215,7508]],[[289487,740050],[1010,552]],[[273600,686784],[707,-5555]],[[274307,681229],[971,-4407]],[[275278,676822],[1044,-3340]],[[276322,673482],[-873,1608],[249,-2231]],[[275698,672859],[1451,-6955]],[[277149,665904],[514,-3783],[-327,-4089]],[[277336,658032],[-578,-3241]],[[276758,654791],[-1026,-1036]],[[275732,653755],[-1039,-109]],[[274693,653646],[-8,1358]],[[274685,655004],[-699,2747],[-974,902]],[[273012,658653],[-419,2677]],[[272593,661330],[-481,693]],[[272112,662023],[-76,2012]],[[272036,664035],[-620,-123]],[[271416,663912],[-960,3873]],[[270456,667785],[653,1841],[-497,729],[-226,-1422]],[[270386,668933],[-507,756]],[[269879,669689],[508,3791]],[[270387,673480],[25,2380]],[[270412,675860],[-1775,3344],[-1122,2808]],[[266544,683066],[-941,-1164]],[[265603,681902],[-2600,-1346]],[[263003,680556],[-97,1158]],[[262906,681714],[-1117,1726]],[[261789,683440],[-1941,1375]],[[259848,684815],[-2876,-127]],[[263868,688385],[343,-1663],[7345,-921],[494,-954],[351,2459],[1199,-522]],[[275354,694475],[-669,-895]],[[274685,693580],[-486,-3642],[-426,-380]],[[273773,689558],[-173,-2774]],[[262156,712078],[3609,-75]],[[265765,712003],[3360,78]],[[269125,712081],[-669,-1736],[1411,-1608],[718,-2485],[1621,-2930],[1433,-3479],[1153,-4894],[562,-474]],[[67829,617353],[-833,347],[-465,4024],[635,1566]],[[67166,623290],[-33,1550]],[[67133,624840],[1759,-1668],[1095,-2783]],[[69987,620389],[-1404,-1566]],[[68583,618823],[-754,-1470]],[[65314,628731],[1381,-1039]],[[66695,627692],[-1247,-826],[-134,1865]],[[63295,630407],[1393,-358]],[[64688,630049],[-410,-585]],[[64278,629464],[-983,943]],[[61668,631836],[456,-883]],[[62124,630953],[-1320,65]],[[60804,631018],[-453,1580]],[[60351,632598],[863,688]],[[61214,633286],[454,-1450]],[[57298,634654],[-1158,648],[1215,1054]],[[57355,636356],[-57,-1702]],[[248192,756583],[1342,-2445],[-500,-2774],[-1946,-1558],[262,-2094],[-1356,-3769]],[[245994,743943],[-804,1453],[-5606,-69],[-5598,-66]],[[233986,745261],[-894,7225],[-1095,4087]],[[231997,756573],[-391,1293],[467,4571]],[[232073,762437],[4516,2],[9952,6]],[[246541,762445],[488,-4229],[1163,-1633]],[[191524,768349],[3,-14800]],[[191527,753549],[-8312,2]],[[183215,753551],[-8261,-4]],[[174954,753547],[-10,10711],[301,2067],[-806,1652],[1813,6046],[79,1571],[-1045,1660]],[[175286,777254],[-356,2530],[-40,15190]],[[177643,794974],[-1,-5855],[875,-1768],[87,-1592],[981,-1046],[2077,-3560],[696,44],[-495,-6470],[643,-521],[986,1149],[1678,-5175],[942,-1941],[2134,476],[2032,-94],[450,1184],[796,-1456]],[[256077,756489],[41,-1692]],[[256118,754797],[767,-2866]],[[256885,751931],[-25,-16700],[-293,-2170],[-659,-1467],[-469,-2998]],[[255439,728596],[-182,-1509],[-1076,-1007],[64,-1635],[-1596,560],[-300,-1130]],[[252349,723875],[-924,1307],[-101,2558],[-1806,2515],[-558,1426],[687,3098],[-1491,918],[-710,2366],[-1407,2606],[-45,3274]],[[248192,756583],[7885,-94]],[[264455,751775],[-71,-15460]],[[264384,736315],[58,-1614],[-1789,-808],[-38,-905],[-1792,-3190],[-586,1018],[-650,-1461],[-890,350],[-756,-1013],[-642,701],[-1860,-797]],[[256885,751931],[1830,194]],[[258715,752125],[5739,-4],[1,-346]],[[237172,723926],[-5135,0],[-6419,0],[-8987,0]],[[216598,741701],[7569,0],[6405,0],[4575,3]],[[235147,741704],[1158,-829],[-355,-2089],[1210,-2279],[12,-12581]],[[270520,732502],[66,-2065],[858,-2444],[875,-874]],[[272319,727119],[-1990,-2379],[-1302,-2226],[-1437,-932]],[[267590,721582],[-175,-108],[-7714,462],[-3699,-124],[-611,-854],[-3865,1]],[[251526,720959],[817,1278],[6,1638]],[[264384,736315],[1288,-249],[363,-1094],[1493,-1278],[2248,360],[744,-1552]],[[251331,683591],[-1957,1106],[-522,-1415]],[[248852,683282],[661,-659]],[[249513,682623],[1216,846]],[[250729,683469],[1064,-2084],[-1018,-1190]],[[250775,680195],[576,-1180]],[[251351,679015],[1383,-1287]],[[252734,677728],[-939,-786],[-1454,2297],[-487,-157]],[[249854,679082],[-446,-1934]],[[249408,677148],[-463,1127]],[[248945,678275],[-1032,-973],[-1497,937],[127,996],[-1802,2244]],[[244741,681479],[-1021,-1654]],[[243720,679825],[-1141,239],[-1400,1077],[-1968,184]],[[239211,681325],[250,991]],[[239461,682316],[205,3442],[527,2891],[-1425,5646],[6,6000]],[[246806,700318],[0,-2316],[561,-2280],[-941,-2110],[-921,-3596],[-107,-1630],[5336,-4],[-277,-1602],[874,-3189]],[[302128,751805],[-426,1839],[-1149,-29]],[[295887,753873],[631,4128]],[[296518,758001],[2185,-130]],[[298703,757871],[3159,-165],[1454,1032]],[[303316,758738],[196,-1229]],[[303512,757509],[-863,-2003]],[[302649,755506],[856,-606],[621,-2521]],[[304126,752379],[1425,135]],[[305551,752514],[147,-883]],[[305698,751631],[-1968,-847],[-122,1071]],[[303608,751855],[-1299,-1336]],[[302309,750519],[-181,1286]],[[291562,732546],[-940,-2552]],[[290622,729994],[-787,-421]],[[289835,729573],[-532,105]],[[289303,729678],[-105,2275],[-897,34]],[[288301,731987],[-146,1414]],[[288155,733401],[731,10]],[[288886,733411],[-652,3495]],[[288234,736906],[1007,1892],[-1521,-1694],[-224,-4105]],[[287496,732999],[130,-2125]],[[287626,730874],[-2186,1904]],[[285440,732778],[587,2337]],[[286027,735115],[-1935,2708]],[[284092,737823],[-357,1501],[-905,509],[-873,-902],[-784,552],[-1973,-2463],[29,3033]],[[279229,740053],[10258,-3]],[[313542,772321],[-185,-2926]],[[313357,769395],[-1799,-588]],[[311558,768807],[-605,-1137]],[[310953,767670],[-1094,730]],[[309859,768400],[-228,-1475],[-1191,1038]],[[308440,767963],[-297,-1992]],[[308143,765971],[-2055,-1928]],[[306088,764043],[-436,492],[-2133,-4652]],[[303519,759883],[-618,1892],[-358,11284]],[[270430,756850],[-395,631]],[[270035,757481],[-1853,-5444]],[[268182,752037],[-3727,-262]],[[258715,752125],[639,894]],[[259354,753019],[925,2999]],[[260279,756018],[215,2861]],[[260494,758879],[-889,4365]],[[259605,763244],[64,2586]],[[259669,765830],[620,1522]],[[260289,767352],[164,2308],[1723,2801],[186,-2496],[483,2993]],[[262845,772958],[1135,685]],[[263980,773643],[-40,2219]],[[263940,775862],[582,164]],[[264522,776026],[3593,-2701]],[[268115,773325],[512,-3884]],[[268627,769441],[-101,-1826]],[[268526,767615],[-1523,-2444]],[[267003,765171],[399,-1990]],[[267402,763181],[979,1761]],[[268381,764942],[1210,848]],[[269591,765790],[788,-1261]],[[270379,764529],[684,-4958]],[[256650,771959],[-721,1537],[157,1886],[-952,1536],[-5464,2348],[-802,1488]],[[248868,780754],[2923,1713],[1957,2074]],[[253748,784541],[560,-2551]],[[254308,781990],[647,889]],[[254955,782879],[1606,-741],[777,-1790],[2002,-424],[1404,1387]],[[260744,781311],[3234,497]],[[263978,781808],[-185,-1570]],[[263793,780238],[1964,-86]],[[265757,780152],[279,-1823]],[[266036,778329],[906,-1200],[-1723,81],[-780,-712]],[[264439,776498],[-1881,1381],[-3012,-1333]],[[259546,776546],[-1141,-944]],[[258405,775602],[-23,1149]],[[258382,776751],[-1732,-4792]],[[254939,784414],[-604,-1184]],[[254335,783230],[-490,967]],[[253845,784197],[615,1271]],[[254460,785468],[1815,267]],[[256275,785735],[-1336,-1321]],[[251173,789100],[-3832,-2831]],[[247341,786269],[-3194,-4511]],[[244147,781758],[-513,-602],[-3,-3414],[-1114,-1040],[-553,-1861],[516,-598],[-256,-4170],[3935,-4735],[382,-2893]],[[232073,762437],[0,10641],[-1088,1770],[803,2055]],[[231788,776903],[-640,4178],[-200,5571],[-742,3466],[-278,4857]],[[251526,720959],[-706,-2952]],[[237171,720965],[1,2961]],[[235147,741704],[-1161,3557]],[[254445,684648],[-1404,262]],[[253041,684910],[-1710,-1319]],[[249184,712079],[5890,-12]],[[211080,776905],[-72,-5581]],[[211008,771324],[-4871,0],[-8525,0],[-6087,0],[-1,-2975]],[[211017,794974],[63,-18069]],[[281766,705417],[-3026,5452],[-3141,189],[-842,1937],[-3524,272],[-2108,-1186]],[[265765,712003],[104,1260],[1155,1860],[2447,1692],[285,896],[2323,1014],[882,1378],[208,1511]],[[273169,721614],[3063,-141],[6030,-100],[6720,-109]],[[288982,721264],[232,-2226]],[[289214,719038],[-2343,-1292]],[[286871,717746],[2650,-342]],[[289521,717404],[-4,-1498]],[[289517,715906],[-1111,-1735]],[[288406,714171],[-996,914]],[[287410,715085],[-1228,-297],[1282,-1113]],[[287464,713675],[-11,-2922]],[[287453,710753],[-1714,-411]],[[285739,710342],[-1714,-2505]],[[284025,707837],[-491,-2046]],[[283534,705791],[-1768,-374]],[[231788,776903],[-9060,0],[-7118,0],[-4530,2]],[[211051,747624],[-20,11852]],[[211031,759476],[9614,0],[5906,-2],[827,-881],[2514,49],[2105,-2069]],[[303519,759883],[-203,-1145]],[[298703,757871],[-230,926],[457,3807],[983,4571],[967,885],[460,3302]],[[291460,741597],[943,1013],[-1262,2615],[184,2389],[1177,2122]],[[292502,749736],[2192,-2162]],[[294694,747574],[-921,-3176]],[[293773,744398],[789,-758],[-607,-3565],[-1718,-4293]],[[292237,735782],[-2027,2893]],[[290210,738675],[284,1774],[966,1148]],[[213888,720966],[-116,-3],[-66,-26647],[-10007,-11],[619,-1378]],[[204318,692927],[-4907,65]],[[199411,692992],[-7,-2665]],[[199404,690327],[-2315,-10]],[[213886,723926],[2,-2960]],[[166664,753547],[8290,0]],[[183215,753551],[6,-29605]],[[300269,747979],[-3587,-2408]],[[296682,745571],[-2327,-92]],[[294355,745479],[1273,1665],[4641,835]],[[296244,771350],[86,-4365],[-195,-4089],[400,-151],[-17,-4744]],[[295471,747571],[-991,-1425],[214,1428]],[[292502,749736],[-899,1159],[-913,2639],[-4595,6],[-7659,10],[0,1627]],[[278436,755177],[2459,3101]],[[280895,758278],[-523,1818]],[[280385,761144],[2462,663]],[[282847,761807],[1794,-755],[1535,60]],[[286176,761112],[2069,1617],[-157,1768]],[[288088,764497],[604,887]],[[288692,765384],[-782,637]],[[287910,766021],[1540,2279]],[[276336,745528],[-1234,-6373],[-1928,-1773],[-626,-2238],[-329,693],[-881,-3149],[-818,-186]],[[268182,752037],[2706,-2059]],[[270888,749978],[2103,669]],[[272991,750647],[1374,1537]],[[274365,752184],[1967,1298]],[[276332,753482],[4,-7954]],[[237544,704065],[-2094,1826],[-1955,-437],[-1037,-1017],[-1841,1344],[-393,-1204],[-1503,1512],[-506,-676],[-711,1594],[-1098,-333],[-1924,869],[-483,1308],[-756,-401],[-1019,1174],[-9,11341],[-8327,1]],[[157719,778152],[775,-229],[601,-2614],[1453,-490],[1500,753],[1715,-468],[1927,510],[3699,1630],[5897,10]],[[154920,753549],[-351,723]],[[154569,754272],[-513,4087],[1086,5208]],[[155142,763567],[249,6434]],[[155391,770001],[361,4735]],[[155752,774736],[49,3585]],[[155801,778321],[1918,-169]],[[291460,741597],[-963,-995]],[[279229,740053],[-2895,-2],[2,5477]],[[276332,753482],[2104,1695]],[[302128,751805],[-583,-1497]],[[301545,750308],[-1107,-701]],[[281766,705417],[-770,-903]],[[280996,704514],[-1078,-3193]],[[279918,701321],[-2201,-3349]],[[277717,697972],[-947,-706]],[[276770,697266],[-1416,-2791]],[[211031,759476],[-23,11848]],[[267590,721582],[5579,32]],[[239461,682316],[-849,-1818]],[[238612,680498],[-1885,-726]],[[236727,679772],[101,1197],[-781,-282]],[[236047,680687],[374,-1965]],[[236421,678722],[-1070,-2410]],[[235351,676312],[-1612,-1917]],[[233739,674395],[-2185,405],[609,-1489]],[[232163,673311],[-1703,-2153]],[[230460,671158],[-707,-2508]],[[229753,668650],[-739,-4166],[424,-3382]],[[229438,661102],[711,-2578],[-590,-538]],[[229559,657986],[-2011,1148],[-2847,2266],[-933,3494],[-172,3032]],[[223596,667926],[-2196,4617]],[[221400,672543],[-1008,4389]],[[220392,676932],[-2171,4196],[-2508,523],[-1087,-1310]],[[214626,680341],[-364,-2286]],[[214262,678055],[-1089,-1522]],[[213173,676533],[-2369,2281]],[[210804,678814],[-1093,1727]],[[209711,680541],[-1319,5736],[-825,956]],[[207567,687233],[-2722,4369]],[[204845,691602],[-527,1325]],[[191527,753549],[0,-5925],[5566,0]],[[290622,729994],[-612,-2333]],[[290010,727661],[-1114,-2176]],[[288896,725485],[939,4088]],[[286027,735115],[-787,-2919]],[[285240,732196],[2122,-1790]],[[287362,730406],[793,-1190]],[[288155,729216],[2,-3179]],[[288157,726037],[-965,-204]],[[287192,725833],[910,-1599]],[[288102,724234],[-323,-965]],[[287779,723269],[1111,136],[92,-2141]],[[272319,727119],[1112,-1954],[1725,545],[1743,1231],[86,992],[1910,5332],[993,-596],[553,1856],[1683,2198],[314,1724],[1351,-1816],[303,1192]],[[157719,778152],[-677,696]],[[157042,778848],[-1689,49]],[[155353,778897],[-108,4478]],[[155245,783375],[-734,3693]],[[154511,787068],[-681,1455]],[[153830,788523],[-247,2821]],[[153583,791344],[2040,-1255]],[[155623,790089],[2642,-516]],[[158265,789573],[683,333]],[[158948,789906],[557,-5003]],[[159505,784903],[623,464],[-109,2660],[419,1128],[-1185,2692]],[[159253,791847],[344,1760]],[[159597,793607],[-677,1367]],[[258356,772744],[-801,-2405]],[[257555,770339],[-323,707]],[[257232,771046],[1124,1698]],[[256650,771959],[-1009,-3296]],[[255641,768663],[1013,1747]],[[256654,770410],[742,-421]],[[257396,769989],[-1568,-9070],[249,-4430]],[[244147,781758],[1689,23]],[[245836,781781],[1536,1111]],[[247372,782892],[581,-1569]],[[247953,781323],[915,-569]]],"transform":{"scale":[0.00036000036000036,0.00016879196566696583],"translate":[-180,-85.19218750000006]}} diff --git a/assets/icons.go b/assets/icons.go new file mode 100644 index 00000000..7ffa780d --- /dev/null +++ b/assets/icons.go @@ -0,0 +1,8 @@ +package assets + +import ( + _ "embed" +) + +//go:embed data/icons/pm_light_512.png +var PNG []byte diff --git a/assets/icons_default.go b/assets/icons_default.go new file mode 100644 index 00000000..2530f309 --- /dev/null +++ b/assets/icons_default.go @@ -0,0 +1,103 @@ +//go:build !windows + +package assets + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/png" + + "golang.org/x/image/draw" + + "github.com/safing/portbase/log" +) + +// Colored Icon IDs. +const ( + GreenID = 0 + YellowID = 1 + RedID = 2 + BlueID = 3 +) + +// Icons. +var ( + //go:embed data/icons/pm_light_green_512.png + GreenPNG []byte + + //go:embed data/icons/pm_light_yellow_512.png + YellowPNG []byte + + //go:embed data/icons/pm_light_red_512.png + RedPNG []byte + + //go:embed data/icons/pm_light_blue_512.png + BluePNG []byte + + // ColoredIcons holds all the icons as .PNGs. + ColoredIcons [4][]byte +) + +func init() { + setColoredIcons() +} + +func setColoredIcons() { + ColoredIcons = [4][]byte{ + GreenID: GreenPNG, + YellowID: YellowPNG, + RedID: RedPNG, + BlueID: BluePNG, + } +} + +// ScaleColoredIconsTo scales all colored icons to the given size. +// It must be called before any colored icons are used. +// It does nothing on Windows. +func ScaleColoredIconsTo(pixelSize int) { + // Scale colored icons only. + GreenPNG = quickScalePNG(GreenPNG, pixelSize) + YellowPNG = quickScalePNG(YellowPNG, pixelSize) + RedPNG = quickScalePNG(RedPNG, pixelSize) + BluePNG = quickScalePNG(BluePNG, pixelSize) + + // Repopulate colored icons. + setColoredIcons() +} + +func quickScalePNG(imgData []byte, pixelSize int) []byte { + scaledImage, err := scalePNGTo(imgData, pixelSize) + if err != nil { + log.Warningf("failed to scale image (using original): %s", err) + return imgData + } + return scaledImage +} + +func scalePNGTo(imgData []byte, pixelSize int) ([]byte, error) { + img, err := png.Decode(bytes.NewReader(imgData)) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + // Return data unprocessed if image already has the correct size. + if img.Bounds().Dx() == pixelSize { + return imgData, nil + } + + // Scale image to given size. + rectangle := image.Rect(0, 0, pixelSize, pixelSize) + scaledImage := image.NewRGBA(rectangle) + draw.CatmullRom.Scale(scaledImage, rectangle, img, img.Bounds(), draw.Over, nil) + + // Encode scaled image. + scaledImgBuffer := new(bytes.Buffer) + err = png.Encode(scaledImgBuffer, scaledImage) + if err != nil { + return nil, fmt.Errorf("failed to encode image: %w", err) + } + + return scaledImgBuffer.Bytes(), nil +} diff --git a/assets/icons_windows.go b/assets/icons_windows.go new file mode 100644 index 00000000..83f5db2e --- /dev/null +++ b/assets/icons_windows.go @@ -0,0 +1,41 @@ +package assets + +import ( + _ "embed" +) + +// Colored Icon IDs. +const ( + GreenID = 0 + YellowID = 1 + RedID = 2 + BlueID = 3 +) + +// Icons. +var ( + //go:embed data/icons/pm_light_green_512.ico + GreenICO []byte + + //go:embed data/icons/pm_light_yellow_512.ico + YellowICO []byte + + //go:embed data/icons/pm_light_red_512.ico + RedICO []byte + + //go:embed data/icons/pm_light_blue_512.ico + BlueICO []byte + + // ColoredIcons holds all the icons as .ICOs + ColoredIcons = [4][]byte{ + GreenID: GreenICO, + YellowID: YellowICO, + RedID: RedICO, + BlueID: BlueICO, + } +) + +// ScaleColoredIconsTo scales all colored icons to the given size. +// It must be called before any colored icons are used. +// It does nothing on Windows. +func ScaleColoredIconsTo(pixelSize int) {} diff --git a/cmds/hub/.gitignore b/cmds/hub/.gitignore new file mode 100644 index 00000000..41668e89 --- /dev/null +++ b/cmds/hub/.gitignore @@ -0,0 +1,3 @@ +# Compiled binaries +hub +hub.exe diff --git a/cmds/hub/build b/cmds/hub/build new file mode 100755 index 00000000..055874ef --- /dev/null +++ b/cmds/hub/build @@ -0,0 +1,60 @@ +#!/bin/bash + +# get build data +if [[ "$BUILD_COMMIT" == "" ]]; then + BUILD_COMMIT=$(git describe --all --long --abbrev=99 --dirty 2>/dev/null) +fi +if [[ "$BUILD_USER" == "" ]]; then + BUILD_USER=$(id -un) +fi +if [[ "$BUILD_HOST" == "" ]]; then + BUILD_HOST=$(hostname -f) +fi +if [[ "$BUILD_DATE" == "" ]]; then + BUILD_DATE=$(date +%d.%m.%Y) +fi +if [[ "$BUILD_SOURCE" == "" ]]; then + BUILD_SOURCE=$(git remote -v | grep origin | cut -f2 | cut -d" " -f1 | head -n 1) +fi +if [[ "$BUILD_SOURCE" == "" ]]; then + BUILD_SOURCE=$(git remote -v | cut -f2 | cut -d" " -f1 | head -n 1) +fi +BUILD_BUILDOPTIONS=$(echo $* | sed "s/ /§/g") + +# check +if [[ "$BUILD_COMMIT" == "" ]]; then + echo "could not automatically determine BUILD_COMMIT, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_USER" == "" ]]; then + echo "could not automatically determine BUILD_USER, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_HOST" == "" ]]; then + echo "could not automatically determine BUILD_HOST, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_DATE" == "" ]]; then + echo "could not automatically determine BUILD_DATE, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_SOURCE" == "" ]]; then + echo "could not automatically determine BUILD_SOURCE, please supply manually as environment variable." + exit 1 +fi + +# set build options +export CGO_ENABLED=0 +if [[ $1 == "dev" ]]; then + shift + export CGO_ENABLED=1 + DEV="-race" +fi + +echo "Please notice, that this build script includes metadata into the build." +echo "This information is useful for debugging and license compliance." +echo "Run the compiled binary with the -version flag to see the information included." + +# build +BUILD_PATH="github.com/safing/portbase/info" +go build $DEV -ldflags "-X ${BUILD_PATH}.commit=${BUILD_COMMIT} -X ${BUILD_PATH}.buildOptions=${BUILD_BUILDOPTIONS} -X ${BUILD_PATH}.buildUser=${BUILD_USER} -X ${BUILD_PATH}.buildHost=${BUILD_HOST} -X ${BUILD_PATH}.buildDate=${BUILD_DATE} -X ${BUILD_PATH}.buildSource=${BUILD_SOURCE}" $* diff --git a/cmds/hub/main.go b/cmds/hub/main.go new file mode 100644 index 00000000..74c7e316 --- /dev/null +++ b/cmds/hub/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "flag" + "fmt" + "os" + "runtime" + + "github.com/safing/portbase/info" + "github.com/safing/portbase/metrics" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/run" + _ "github.com/safing/portmaster/service/core/base" + _ "github.com/safing/portmaster/service/ui" + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/service/updates/helper" + _ "github.com/safing/portmaster/spn/captain" + "github.com/safing/portmaster/spn/conf" +) + +func init() { + flag.BoolVar(&updates.RebootOnRestart, "reboot-on-restart", false, "reboot server on auto-upgrade") +} + +func main() { + info.Set("SPN Hub", "0.7.7", "GPLv3") + + // Configure metrics. + _ = metrics.SetNamespace("hub") + + // Configure updating. + updates.UserAgent = fmt.Sprintf("SPN Hub (%s %s)", runtime.GOOS, runtime.GOARCH) + helper.IntelOnly() + + // Configure SPN mode. + conf.EnablePublicHub(true) + conf.EnableClient(false) + + // Disable module management, as we want to start all modules. + modules.DisableModuleManagement() + + // Configure microtask threshold. + // Scale with CPU/GOMAXPROCS count, but keep a baseline and minimum: + // CPUs -> MicroTasks + // 0 -> 8 (increased to minimum) + // 1 -> 8 (increased to minimum) + // 2 -> 8 + // 3 -> 10 + // 4 -> 12 + // 8 -> 20 + // 16 -> 36 + // + // Start with number of GOMAXPROCS. + microTasksThreshold := runtime.GOMAXPROCS(0) * 2 + // Use at least 4 microtasks based on GOMAXPROCS. + if microTasksThreshold < 4 { + microTasksThreshold = 4 + } + // Add a 4 microtask baseline. + microTasksThreshold += 4 + // Set threshold. + modules.SetMaxConcurrentMicroTasks(microTasksThreshold) + + // Start. + os.Exit(run.Run()) +} diff --git a/cmds/portmaster-core/pack b/cmds/hub/pack similarity index 79% rename from cmds/portmaster-core/pack rename to cmds/hub/pack index 5bdc4c6f..73c20270 100755 --- a/cmds/portmaster-core/pack +++ b/cmds/hub/pack @@ -10,15 +10,15 @@ COL_GREEN="\033[32m" COL_YELLOW="\033[33m" destDirPart1="../../dist" -destDirPart2="core" +destDirPart2="hub" function prep { # output - output="portmaster-core" + output="main" # get version version=$(grep "info.Set" main.go | cut -d'"' -f4) # build versioned file name - filename="portmaster-core_v${version//./-}" + filename="spn-hub_v${version//./-}" # platform platform="${GOOS}_${GOARCH}" if [[ $GOOS == "windows" ]]; then @@ -34,9 +34,9 @@ function check { # check if file exists if [[ -f $destPath ]]; then - echo "[core] $platform v$version already built" + echo "[hub] $platform v$version already built" else - echo -e "${COL_BOLD}[core] $platform v$version${COL_OFF}" + echo -e "${COL_BOLD}[hub] $platform v$version${COL_OFF}" fi } @@ -45,28 +45,28 @@ function build { # check if file exists if [[ -f $destPath ]]; then - echo "[core] $platform already built in v$version, skipping..." + echo "[hub] $platform already built in v$version, skipping..." return fi # build - ./build + ./build main.go if [[ $? -ne 0 ]]; then - echo -e "\n${COL_BOLD}[core] $platform v$version: ${COL_RED}BUILD FAILED.${COL_OFF}" + echo -e "\n${COL_BOLD}[hub] $platform v$version: ${COL_RED}BUILD FAILED.${COL_OFF}" exit 1 fi mkdir -p $(dirname $destPath) cp $output $destPath - echo -e "\n${COL_BOLD}[core] $platform v$version: ${COL_GREEN}successfully built.${COL_OFF}" + echo -e "\n${COL_BOLD}[hub] $platform v$version: ${COL_GREEN}successfully built.${COL_OFF}" } function reset { prep - + # delete if file exists if [[ -f $destPath ]]; then rm $destPath - echo "[core] $platform v$version deleted." + echo "[hub] $platform v$version deleted." fi } diff --git a/cmds/integrationtest/netstate.go b/cmds/integrationtest/netstate.go index f76604c7..5eaaa9c8 100644 --- a/cmds/integrationtest/netstate.go +++ b/cmds/integrationtest/netstate.go @@ -7,9 +7,9 @@ import ( processInfo "github.com/shirou/gopsutil/process" "github.com/spf13/cobra" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/socket" - "github.com/safing/portmaster/network/state" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/socket" + "github.com/safing/portmaster/service/network/state" ) func init() { diff --git a/cmds/notifier/.gitignore b/cmds/notifier/.gitignore new file mode 100644 index 00000000..602ad23c --- /dev/null +++ b/cmds/notifier/.gitignore @@ -0,0 +1,34 @@ +# Compiled binaries +notifier +notifier.exe + +# Go vendor +vendor + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out diff --git a/cmds/notifier/README.md b/cmds/notifier/README.md new file mode 100644 index 00000000..bdfcece8 --- /dev/null +++ b/cmds/notifier/README.md @@ -0,0 +1,5 @@ +### Development Dependencies + +sudo apt install libgtk-3-dev libayatana-appindicator3-dev libwebkitgtk-3.0-dev libgl1-mesa-dev libglu1-mesa-dev libnotify-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + +sudo pacman -S libappindicator-gtk3 diff --git a/cmds/notifier/http_api.go b/cmds/notifier/http_api.go new file mode 100644 index 00000000..356b81cd --- /dev/null +++ b/cmds/notifier/http_api.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "time" + + "github.com/safing/portbase/log" +) + +const ( + apiBaseURL = "http://127.0.0.1:817/api/v1/" + apiShutdownEndpoint = "core/shutdown" +) + +var httpAPIClient *http.Client + +func init() { + // Make cookie jar. + jar, err := cookiejar.New(nil) + if err != nil { + log.Warningf("http-api: failed to create cookie jar: %s", err) + jar = nil + } + + // Create client. + httpAPIClient = &http.Client{ + Jar: jar, + Timeout: 3 * time.Second, + } +} + +func httpAPIAction(endpoint string) (response string, err error) { + // Make action request. + resp, err := httpAPIClient.Post(apiBaseURL+endpoint, "", nil) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + + // Read the response body. + defer func() { _ = resp.Body.Close() }() + respData, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read data: %w", err) + } + response = strings.TrimSpace(string(respData)) + + // Check if the request was successful on the server. + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return response, fmt.Errorf("server failed with %s: %s", resp.Status, response) + } + + return response, nil +} + +// TriggerShutdown triggers a shutdown via the APi. +func TriggerShutdown() error { + _, err := httpAPIAction(apiShutdownEndpoint) + return err +} diff --git a/cmds/notifier/icons.go b/cmds/notifier/icons.go new file mode 100644 index 00000000..b3690a3f --- /dev/null +++ b/cmds/notifier/icons.go @@ -0,0 +1,25 @@ +package main + +import ( + "os" + "path/filepath" + "sync" + + icons "github.com/safing/portmaster/assets" +) + +var ( + appIconEnsureOnce sync.Once + appIconPath string +) + +func ensureAppIcon() (location string, err error) { + appIconEnsureOnce.Do(func() { + if appIconPath == "" { + appIconPath = filepath.Join(dataDir, "exec", "portmaster.png") + } + err = os.WriteFile(appIconPath, icons.PNG, 0o0644) // nolint:gosec + }) + + return appIconPath, err +} diff --git a/cmds/notifier/main.go b/cmds/notifier/main.go new file mode 100644 index 00000000..a109d01a --- /dev/null +++ b/cmds/notifier/main.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "runtime" + "runtime/pprof" + "strings" + "sync" + "syscall" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/api/client" + "github.com/safing/portbase/dataroot" + "github.com/safing/portbase/info" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/updater" + "github.com/safing/portbase/utils" + "github.com/safing/portmaster/service/updates/helper" +) + +var ( + dataDir string + printStackOnExit bool + showVersion bool + + apiClient = client.NewClient("127.0.0.1:817") + connected = abool.New() + shuttingDown = abool.New() + restarting = abool.New() + + mainCtx, cancelMainCtx = context.WithCancel(context.Background()) + mainWg = &sync.WaitGroup{} + + dataRoot *utils.DirStructure + // Create registry. + registry = &updater.ResourceRegistry{ + Name: "updates", + UpdateURLs: []string{ + "https://updates.safing.io", + }, + DevMode: false, + Online: false, // disable download of resources (this is job for the core). + } +) + +const query = "query " + +func init() { + flag.StringVar(&dataDir, "data", "", "set data directory") + flag.BoolVar(&printStackOnExit, "print-stack-on-exit", false, "prints the stack before of shutting down") + flag.BoolVar(&showVersion, "version", false, "show version and exit") + + runtime.GOMAXPROCS(2) +} + +func main() { + // parse flags + flag.Parse() + + // set meta info + info.Set("Portmaster Notifier", "0.3.6", "GPLv3") + + // check if meta info is ok + err := info.CheckVersion() + if err != nil { + fmt.Println("compile error: please compile using the provided build script") + os.Exit(1) + } + + // print help + if modules.HelpFlag { + flag.Usage() + os.Exit(0) + } + + if showVersion { + fmt.Println(info.FullVersion()) + os.Exit(0) + } + + // auto detect + if dataDir == "" { + dataDir = detectDataDir() + } + + // check data dir + if dataDir == "" { + fmt.Fprintln(os.Stderr, "please set the data directory using --data=/path/to/data/dir") + os.Exit(1) + } + + // switch to safe exec dir + err = os.Chdir(filepath.Join(dataDir, "exec")) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to switch to safe exec dir: %s\n", err) + } + + // start log writer + err = log.Start() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to start logging: %s\n", err) + os.Exit(1) + } + + // load registry + err = configureRegistry(true) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load registry: %s\n", err) + os.Exit(1) + } + + // connect to API + go apiClient.StayConnected() + go apiStatusMonitor() + + // start subsystems + go tray() + go subsystemsClient() + go spnStatusClient() + go notifClient() + go startShutdownEventListener() + + // Shutdown + // catch interrupt for clean shutdown + signalCh := make(chan os.Signal, 1) + signal.Notify( + signalCh, + os.Interrupt, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, + ) + + // wait for shutdown + select { + case <-signalCh: + fmt.Println(" ") + log.Warning("program was interrupted, shutting down") + case <-mainCtx.Done(): + log.Warning("program is shutting down") + } + + if printStackOnExit { + fmt.Println("=== PRINTING STACK ===") + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2) + fmt.Println("=== END STACK ===") + } + go func() { + time.Sleep(10 * time.Second) + fmt.Println("===== TAKING TOO LONG FOR SHUTDOWN - PRINTING STACK TRACES =====") + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2) + os.Exit(1) + }() + + // clear all notifications + clearNotifications() + + // shutdown + cancelMainCtx() + mainWg.Wait() + + apiClient.Shutdown() + exitTray() + log.Shutdown() + + os.Exit(0) +} + +func apiStatusMonitor() { + for { + // Wait for connection. + <-apiClient.Online() + connected.Set() + triggerTrayUpdate() + + // Wait for lost connection. + <-apiClient.Offline() + connected.UnSet() + triggerTrayUpdate() + } +} + +func detectDataDir() string { + // get path of executable + binPath, err := os.Executable() + if err != nil { + return "" + } + // get directory + binDir := filepath.Dir(binPath) + // check if we in the updates directory + identifierDir := filepath.Join("updates", runtime.GOOS+"_"+runtime.GOARCH, "notifier") + // check if there is a match and return data dir + if strings.HasSuffix(binDir, identifierDir) { + return filepath.Clean(strings.TrimSuffix(binDir, identifierDir)) + } + return "" +} + +func configureRegistry(mustLoadIndex bool) error { + // If dataDir is not set, check the environment variable. + if dataDir == "" { + dataDir = os.Getenv("PORTMASTER_DATA") + } + + // If it's still empty, try to auto-detect it. + if dataDir == "" { + dataDir = detectInstallationDir() + } + + // Finally, if it's still empty, the user must provide it. + if dataDir == "" { + return errors.New("please set the data directory using --data=/path/to/data/dir") + } + + // Remove left over quotes. + dataDir = strings.Trim(dataDir, `\"`) + // Initialize data root. + err := dataroot.Initialize(dataDir, 0o0755) + if err != nil { + return fmt.Errorf("failed to initialize data root: %w", err) + } + dataRoot = dataroot.Root() + + // Initialize registry. + err = registry.Initialize(dataRoot.ChildDir("updates", 0o0755)) + if err != nil { + return err + } + + return updateRegistryIndex(mustLoadIndex) +} + +func detectInstallationDir() string { + exePath, err := filepath.Abs(os.Args[0]) + if err != nil { + return "" + } + + parent := filepath.Dir(exePath) // parent should be "...\updates\windows_amd64\notifier" + stableJSONFile := filepath.Join(parent, "..", "..", "stable.json") // "...\updates\stable.json" + stat, err := os.Stat(stableJSONFile) + if err != nil { + return "" + } + + if stat.IsDir() { + return "" + } + + return parent +} + +func updateRegistryIndex(mustLoadIndex bool) error { + // Set indexes based on the release channel. + warning := helper.SetIndexes(registry, "", false, false, false) + if warning != nil { + log.Warningf("%q", warning) + } + + // Load indexes from disk or network, if needed and desired. + err := registry.LoadIndexes(context.Background()) + if err != nil { + log.Warningf("error loading indexes %q", warning) + if mustLoadIndex { + return err + } + } + + // Load versions from disk to know which others we have and which are available. + err = registry.ScanStorage("") + if err != nil { + log.Warningf("error during storage scan: %q\n", err) + } + + registry.SelectVersions() + return nil +} diff --git a/cmds/notifier/notification.go b/cmds/notifier/notification.go new file mode 100644 index 00000000..075dba83 --- /dev/null +++ b/cmds/notifier/notification.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + + pbnotify "github.com/safing/portbase/notifications" +) + +// Notification represents a notification that is to be delivered to the user. +type Notification struct { + pbnotify.Notification + + // systemID holds the ID returned by the dbus interface on Linux or by WinToast library on Windows. + systemID NotificationID +} + +// IsSupportedAction returns whether the action is supported on this system. +func IsSupportedAction(a pbnotify.Action) bool { + switch a.Type { + case pbnotify.ActionTypeNone: + return true + default: + return false + } +} + +// SelectAction sends an action back to the portmaster. +func (n *Notification) SelectAction(action string) { + upd := &pbnotify.Notification{ + EventID: n.EventID, + SelectedActionID: action, + } + + _ = apiClient.Update(fmt.Sprintf("%s%s", dbNotifBasePath, upd.EventID), upd, nil) +} diff --git a/cmds/notifier/notify.go b/cmds/notifier/notify.go new file mode 100644 index 00000000..2286dff6 --- /dev/null +++ b/cmds/notifier/notify.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/safing/portbase/api/client" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + pbnotify "github.com/safing/portbase/notifications" +) + +const ( + dbNotifBasePath = "notifications:all/" +) + +var ( + notifications = make(map[string]*Notification) + notificationsLock sync.Mutex +) + +func notifClient() { + notifOp := apiClient.Qsub(fmt.Sprintf("query %s where ShowOnSystem is true", dbNotifBasePath), handleNotification) + notifOp.EnableResuscitation() + + // start the action listener and block + // until it's closed. + actionListener() +} + +func handleNotification(m *client.Message) { + notificationsLock.Lock() + defer notificationsLock.Unlock() + + log.Tracef("received %s msg: %s", m.Type, m.Key) + + switch m.Type { + case client.MsgError: + case client.MsgDone: + case client.MsgSuccess: + case client.MsgOk, client.MsgUpdate, client.MsgNew: + + n := &Notification{} + _, err := dsd.Load(m.RawValue, &n.Notification) + if err != nil { + log.Warningf("notify: failed to parse new notification: %s", err) + return + } + + // copy existing system values + existing, ok := notifications[n.EventID] + if ok { + existing.Lock() + n.systemID = existing.systemID + existing.Unlock() + } + + // save + notifications[n.EventID] = n + + // Handle notification. + switch { + case existing != nil: + // Cancel existing notification if not active, else ignore. + if n.State != pbnotify.Active { + existing.Cancel() + } + return + case n.State == pbnotify.Active: + // Show new notifications that are active. + n.Show() + default: + // Ignore new notifications that are not active. + } + + case client.MsgDelete: + + n, ok := notifications[strings.TrimPrefix(m.Key, dbNotifBasePath)] + if ok { + n.Cancel() + delete(notifications, n.EventID) + } + + case client.MsgWarning: + case client.MsgOffline: + } +} + +func clearNotifications() { + notificationsLock.Lock() + defer notificationsLock.Unlock() + + for _, n := range notifications { + n.Cancel() + } + + // Wait for goroutines that cancel notifications. + // TODO: Revamp to use a waitgroup. + time.Sleep(1 * time.Second) +} diff --git a/cmds/notifier/notify_linux.go b/cmds/notifier/notify_linux.go new file mode 100644 index 00000000..ba3f638e --- /dev/null +++ b/cmds/notifier/notify_linux.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "errors" + "sync" + + notify "github.com/dhaavi/go-notify" + + "github.com/safing/portbase/log" +) + +type NotificationID uint32 + +var ( + capabilities notify.Capabilities + notifsByID sync.Map +) + +func init() { + var err error + capabilities, err = notify.GetCapabilities() + if err != nil { + log.Errorf("failed to get notification system capabilities: %s", err) + } +} + +func handleActions(ctx context.Context, actions chan notify.Signal) { + mainWg.Add(1) + defer mainWg.Done() + +listenForNotifications: + for { + select { + case <-ctx.Done(): + return + case sig := <-actions: + if sig.Name != "org.freedesktop.Notifications.ActionInvoked" { + // we don't care for anything else (dismissed, closed) + continue listenForNotifications + } + + // get notification by system ID + n, ok := notifsByID.LoadAndDelete(NotificationID(sig.ID)) + + if !ok { + continue listenForNotifications + } + + notification, ok := n.(*Notification) + if !ok { + log.Errorf("received invalid notification type %T", n) + + continue listenForNotifications + } + + log.Tracef("notify: received signal: %+v", sig) + if sig.ActionKey != "" { + // send action + if ok { + notification.Lock() + notification.SelectAction(sig.ActionKey) + notification.Unlock() + } + } else { + log.Tracef("notify: notification clicked: %+v", sig) + // Global action invoked, start the app + launchApp() + } + } + } +} + +func actionListener() { + actions := make(chan notify.Signal, 100) + + go handleActions(mainCtx, actions) + + err := notify.SignalNotify(mainCtx, actions) + if err != nil && errors.Is(err, context.Canceled) { + log.Errorf("notify: signal listener failed: %s", err) + } +} + +// Show shows the notification. +func (n *Notification) Show() { + sysN := notify.NewNotification("Portmaster", n.Message) + // see https://developer.gnome.org/notification-spec/ + + // The optional name of the application sending the notification. + // Can be blank. + sysN.AppName = "Portmaster" + + // The optional notification ID that this notification replaces. + sysN.ReplacesID = uint32(n.systemID) + + // The optional program icon of the calling application. + // sysN.AppIcon string + + // The summary text briefly describing the notification. + // Summary string (arg 1) + + // The optional detailed body text. + // Body string (arg 2) + + // The actions send a request message back to the notification client + // when invoked. + // sysN.Actions []string + if capabilities.Actions { + sysN.Actions = make([]string, 0, len(n.AvailableActions)*2) + for _, action := range n.AvailableActions { + if IsSupportedAction(*action) { + sysN.Actions = append(sysN.Actions, action.ID) + sysN.Actions = append(sysN.Actions, action.Text) + } + } + } + + // Set Portmaster icon. + iconLocation, err := ensureAppIcon() + if err != nil { + log.Warningf("notify: failed to write icon: %s", err) + } + sysN.AppIcon = iconLocation + + // TODO: Use hints to display icon of affected app. + // Hints are a way to provide extra data to a notification server. + // sysN.Hints = make(map[string]interface{}) + + // The timeout time in milliseconds since the display of the + // notification at which the notification should automatically close. + // sysN.Timeout int32 + + newID, err := sysN.Show() + if err != nil { + log.Warningf("notify: failed to show notification %s", n.EventID) + return + } + + notifsByID.Store(NotificationID(newID), n) + + n.Lock() + defer n.Unlock() + n.systemID = NotificationID(newID) +} + +// Cancel cancels the notification. +func (n *Notification) Cancel() { + n.Lock() + defer n.Unlock() + + // TODO: could a ID of 0 be valid? + if n.systemID != 0 { + err := notify.CloseNotification(uint32(n.systemID)) + if err != nil { + log.Warningf("notify: failed to close notification %s/%d", n.EventID, n.systemID) + } + notifsByID.Delete(n.systemID) + } +} diff --git a/cmds/notifier/notify_windows.go b/cmds/notifier/notify_windows.go new file mode 100644 index 00000000..abb56be0 --- /dev/null +++ b/cmds/notifier/notify_windows.go @@ -0,0 +1,184 @@ +package main + +import ( + "fmt" + "sync" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/cmds/notifier/wintoast" + "github.com/safing/portmaster/service/updates/helper" +) + +type NotificationID int64 + +const ( + appName = "Portmaster" + appUserModelID = "io.safing.portmaster.2" + originalShortcutPath = "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Portmaster\\Portmaster.lnk" +) + +const ( + SoundDefault = 0 + SoundSilent = 1 + SoundLoop = 2 +) + +const ( + SoundPathDefault = 0 + // see notification_glue.h if you need more types +) + +var ( + initOnce sync.Once + lib *wintoast.WinToast + notificationsByIDs sync.Map +) + +func getLib() *wintoast.WinToast { + initOnce.Do(func() { + dllPath, err := getDllPath() + if err != nil { + log.Errorf("notify: failed to get dll path: %s", err) + return + } + // Load dll and all the functions + newLib, err := wintoast.New(dllPath) + if err != nil { + log.Errorf("notify: failed to load library: %s", err) + return + } + + // Initialize. This will create or update application shortcut. C:\Users\\AppData\Roaming\Microsoft\Windows\Start Menu\Programs + // and it will be of the originalShortcutPath with no CLSID and different AUMI + err = newLib.Initialize(appName, appUserModelID, originalShortcutPath) + if err != nil { + log.Errorf("notify: failed to load library: %s", err) + return + } + + // library was initialized successfully + lib = newLib + + // Set callbacks + + err = lib.SetCallbacks(notificationActivatedCallback, notificationDismissedCallback, notificationDismissedCallback) + if err != nil { + log.Warningf("notify: failed to set callbacks: %s", err) + return + } + }) + + return lib +} + +// Show shows the notification. +func (n *Notification) Show() { + // Lock notification + n.Lock() + defer n.Unlock() + + // Create new notification object + builder, err := getLib().NewNotification(n.Title, n.Message) + if err != nil { + log.Errorf("notify: failed to create notification: %s", err) + return + } + // Make sure memory is freed when done + defer builder.Delete() + + // if needed set notification icon + // _ = builder.SetImage(iconLocation) + + // Leaving the default value for the sound + // _ = builder.SetSound(SoundDefault, SoundPathDefault) + + // Set all the required actions. + for _, action := range n.AvailableActions { + err = builder.AddButton(action.Text) + if err != nil { + log.Warningf("notify: failed to add button: %s", err) + } + } + + // Show notification. + id, err := builder.Show() + if err != nil { + log.Errorf("notify: failed to show notification: %s", err) + return + } + n.systemID = NotificationID(id) + + // Link system id to the notification object + notificationsByIDs.Store(NotificationID(id), n) + + log.Debugf("notify: showing notification %q: %d", n.Title, n.systemID) +} + +// Cancel cancels the notification. +func (n *Notification) Cancel() { + // Lock notification + n.Lock() + defer n.Unlock() + + // No need to check for errors. If it fails it is probably already dismissed + _ = getLib().HideNotification(int64(n.systemID)) + + notificationsByIDs.Delete(n.systemID) + log.Debugf("notify: notification canceled %q: %d", n.Title, n.systemID) +} + +func notificationActivatedCallback(id int64, actionIndex int32) { + if actionIndex == -1 { + // The user clicked on the notification (not a button), open the portmaster and delete + launchApp() + notificationsByIDs.Delete(NotificationID(id)) + log.Debugf("notify: notification clicked %d", id) + return + } + + // The user click one of the buttons + + // Get notified object + n, ok := notificationsByIDs.LoadAndDelete(NotificationID(id)) + if !ok { + return + } + + notification := n.(*Notification) + + notification.Lock() + defer notification.Unlock() + + // Set selected action + actionID := notification.AvailableActions[actionIndex].ID + notification.SelectAction(actionID) + + log.Debugf("notify: notification button cliecked %d button id: %d", id, actionIndex) +} + +func notificationDismissedCallback(id int64, reason int32) { + // Failure or user dismissed the notification + if reason == 0 { + notificationsByIDs.Delete(NotificationID(id)) + log.Debugf("notify: notification dissmissed %d", id) + } +} + +func getDllPath() (string, error) { + if dataDir == "" { + return "", fmt.Errorf("dataDir is empty") + } + + // Aks the registry for the dll path + identifier := helper.PlatformIdentifier("notifier/portmaster-wintoast.dll") + file, err := registry.GetFile(identifier) + if err != nil { + return "", err + } + return file.Path(), nil +} + +func actionListener() { + // initialize the library + _ = getLib() +} diff --git a/cmds/notifier/shutdown.go b/cmds/notifier/shutdown.go new file mode 100644 index 00000000..f943938d --- /dev/null +++ b/cmds/notifier/shutdown.go @@ -0,0 +1,50 @@ +package main + +import ( + "github.com/safing/portbase/api/client" + "github.com/safing/portbase/log" +) + +func startShutdownEventListener() { + shutdownNotifOp := apiClient.Sub("query runtime:modules/core/event/shutdown", handleShutdownEvent) + shutdownNotifOp.EnableResuscitation() + + restartNotifOp := apiClient.Sub("query runtime:modules/core/event/restart", handleRestartEvent) + restartNotifOp.EnableResuscitation() +} + +func handleShutdownEvent(m *client.Message) { + switch m.Type { + case client.MsgOk, client.MsgUpdate, client.MsgNew: + shuttingDown.Set() + triggerTrayUpdate() + + log.Warningf("shutdown: received shutdown event, shutting down now") + + // wait for the API client connection to die + <-apiClient.Offline() + shuttingDown.UnSet() + + cancelMainCtx() + + case client.MsgWarning, client.MsgError: + log.Errorf("shutdown: event subscription error: %s", string(m.RawValue)) + } +} + +func handleRestartEvent(m *client.Message) { + switch m.Type { + case client.MsgOk, client.MsgUpdate, client.MsgNew: + restarting.Set() + triggerTrayUpdate() + + log.Warningf("restart: received restart event") + + // wait for the API client connection to die + <-apiClient.Offline() + restarting.UnSet() + triggerTrayUpdate() + case client.MsgWarning, client.MsgError: + log.Errorf("shutdown: event subscription error: %s", string(m.RawValue)) + } +} diff --git a/cmds/notifier/snoretoast-guid.patch b/cmds/notifier/snoretoast-guid.patch new file mode 100644 index 00000000..1a050e5f --- /dev/null +++ b/cmds/notifier/snoretoast-guid.patch @@ -0,0 +1,15 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 498226a..446ba5e 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -2,7 +2,9 @@ cmake_minimum_required(VERSION 3.4) + + project(snoretoast VERSION 0.6.0) + # Always change the guid when the version is changed SNORETOAST_CALLBACK_GUID +-set(SNORETOAST_CALLBACK_GUID eb1fdd5b-8f70-4b5a-b230-998a2dc19303) ++#We keep it fixed! ++set(SNORETOAST_CALLBACK_GUID 7F00FB48-65D5-4BA8-A35B-F194DA7E1A51) ++ + + set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake/) + diff --git a/cmds/notifier/spn.go b/cmds/notifier/spn.go new file mode 100644 index 00000000..d313716b --- /dev/null +++ b/cmds/notifier/spn.go @@ -0,0 +1,104 @@ +package main + +import ( + "sync" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/api/client" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" +) + +const ( + spnModuleKey = "config:spn/enable" + spnStatusKey = "runtime:spn/status" +) + +var ( + spnEnabled = abool.New() + + spnStatusCache *SPNStatus + spnStatusCacheLock sync.Mutex +) + +// SPNStatus holds SPN status information. +type SPNStatus struct { + Status string + HomeHubID string + HomeHubName string + ConnectedIP string + ConnectedTransport string + ConnectedSince *time.Time +} + +// GetSPNStatus returns the SPN status. +func GetSPNStatus() *SPNStatus { + spnStatusCacheLock.Lock() + defer spnStatusCacheLock.Unlock() + + return spnStatusCache +} + +func updateSPNStatus(s *SPNStatus) { + spnStatusCacheLock.Lock() + defer spnStatusCacheLock.Unlock() + + spnStatusCache = s +} + +func spnStatusClient() { + moduleQueryOp := apiClient.Qsub(query+spnModuleKey, handleSPNModuleUpdate) + moduleQueryOp.EnableResuscitation() + + statusQueryOp := apiClient.Qsub(query+spnStatusKey, handleSPNStatusUpdate) + statusQueryOp.EnableResuscitation() +} + +func handleSPNModuleUpdate(m *client.Message) { + switch m.Type { + case client.MsgOk, client.MsgUpdate, client.MsgNew: + var cfg struct { + Value bool `json:"Value"` + } + _, err := dsd.Load(m.RawValue, &cfg) + if err != nil { + log.Warningf("config: failed to parse config: %s", err) + return + } + log.Infof("config: received update to SPN module: enabled=%v", cfg.Value) + + spnEnabled.SetTo(cfg.Value) + triggerTrayUpdate() + + default: + } +} + +func handleSPNStatusUpdate(m *client.Message) { + switch m.Type { + case client.MsgOk, client.MsgUpdate, client.MsgNew: + newStatus := &SPNStatus{} + _, err := dsd.Load(m.RawValue, newStatus) + if err != nil { + log.Warningf("config: failed to parse config: %s", err) + return + } + log.Infof("config: received update to SPN status: %+v", newStatus) + + updateSPNStatus(newStatus) + triggerTrayUpdate() + + default: + } +} + +func ToggleSPN() { + var cfg struct { + Value bool `json:"Value"` + } + cfg.Value = !spnEnabled.IsSet() + + apiClient.Update(spnModuleKey, &cfg, nil) +} diff --git a/cmds/notifier/subsystems.go b/cmds/notifier/subsystems.go new file mode 100644 index 00000000..8444bf13 --- /dev/null +++ b/cmds/notifier/subsystems.go @@ -0,0 +1,121 @@ +package main + +import ( + "sync" + + "github.com/safing/portbase/api/client" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" +) + +const ( + subsystemsKeySpace = "runtime:subsystems/" + + // Module Failure Status Values + // FailureNone = 0 // unused + // FailureHint = 1 // unused. + FailureWarning = 2 + FailureError = 3 +) + +var ( + subsystems = make(map[string]*Subsystem) + subsystemsLock sync.Mutex +) + +// Subsystem describes a subset of modules that represent a part of a +// service or program to the user. Subsystems can be (de-)activated causing +// all related modules to be brought down or up. +type Subsystem struct { //nolint:maligned // not worth the effort + // ID is a unique identifier for the subsystem. + ID string + + // Name holds a human readable name of the subsystem. + Name string + + // Description may holds an optional description of + // the subsystem's purpose. + Description string + + // Modules contains all modules that are related to the subsystem. + // Note that this slice also contains a reference to the subsystem + // module itself. + Modules []*ModuleStatus + + // FailureStatus is the worst failure status that is currently + // set in one of the subsystem's dependencies. + FailureStatus uint8 +} + +// ModuleStatus describes the status of a module. +type ModuleStatus struct { + Name string + Enabled bool + Status uint8 + FailureStatus uint8 + FailureID string + FailureMsg string +} + +// GetFailure returns the worst of all subsystem failures. +func GetFailure() (failureStatus uint8, failureMsg string) { + subsystemsLock.Lock() + defer subsystemsLock.Unlock() + + for _, subsystem := range subsystems { + for _, module := range subsystem.Modules { + if failureStatus < module.FailureStatus { + failureStatus = module.FailureStatus + failureMsg = module.FailureMsg + } + } + } + + return +} + +func updateSubsystem(s *Subsystem) { + subsystemsLock.Lock() + defer subsystemsLock.Unlock() + + subsystems[s.ID] = s +} + +func clearSubsystems() { + subsystemsLock.Lock() + defer subsystemsLock.Unlock() + + for key := range subsystems { + delete(subsystems, key) + } +} + +func subsystemsClient() { + subsystemsOp := apiClient.Qsub("query "+subsystemsKeySpace, handleSubsystem) + subsystemsOp.EnableResuscitation() +} + +func handleSubsystem(m *client.Message) { + switch m.Type { + case client.MsgError: + case client.MsgDone: + case client.MsgSuccess: + case client.MsgOk, client.MsgUpdate, client.MsgNew: + + newSubsystem := &Subsystem{} + _, err := dsd.Load(m.RawValue, newSubsystem) + if err != nil { + log.Warningf("subsystems: failed to parse new subsystem: %s", err) + return + } + updateSubsystem(newSubsystem) + triggerTrayUpdate() + + case client.MsgDelete: + case client.MsgWarning: + case client.MsgOffline: + + clearSubsystems() + + } +} diff --git a/cmds/notifier/tray.go b/cmds/notifier/tray.go new file mode 100644 index 00000000..4044d4f7 --- /dev/null +++ b/cmds/notifier/tray.go @@ -0,0 +1,217 @@ +package main + +import ( + "flag" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "fyne.io/systray" + + "github.com/safing/portbase/log" + icons "github.com/safing/portmaster/assets" +) + +const ( + shortenStatusMsgTo = 40 +) + +var ( + trayLock sync.Mutex + + scaleColoredIconsTo int + + activeIconID int = -1 + activeStatusMsg = "" + activeSPNStatus = "" + activeSPNSwitch = "" + + menuItemStatusMsg *systray.MenuItem + menuItemSPNStatus *systray.MenuItem + menuItemSPNSwitch *systray.MenuItem +) + +func init() { + flag.IntVar(&scaleColoredIconsTo, "scale-icons", 32, "scale colored icons to given size in pixels") + + // lock until ready + trayLock.Lock() +} + +func tray() { + if scaleColoredIconsTo > 0 { + icons.ScaleColoredIconsTo(scaleColoredIconsTo) + } + + systray.Run(onReady, onExit) +} + +func exitTray() { + systray.Quit() +} + +func onReady() { + // unlock when ready + defer trayLock.Unlock() + + // icon + systray.SetIcon(icons.ColoredIcons[icons.RedID]) + if runtime.GOOS == "windows" { + // systray.SetTitle("Portmaster Notifier") // Don't set title, as it may be displayed in full in the menu/tray bar. (Ubuntu) + systray.SetTooltip("Portmaster Notifier") + } + + // menu: open app + if dataDir != "" { + menuItemOpenApp := systray.AddMenuItem("Open App", "") + go clickListener(menuItemOpenApp, launchApp) + systray.AddSeparator() + } + + // menu: status + + menuItemStatusMsg = systray.AddMenuItem("Loading...", "") + menuItemStatusMsg.Disable() + systray.AddSeparator() + + // menu: SPN + + menuItemSPNStatus = systray.AddMenuItem("Loading...", "") + menuItemSPNStatus.Disable() + menuItemSPNSwitch = systray.AddMenuItem("Loading...", "") + go clickListener(menuItemSPNSwitch, func() { + ToggleSPN() + }) + systray.AddSeparator() + + // menu: quit + systray.AddSeparator() + closeTray := systray.AddMenuItem("Close Tray Notifier", "") + go clickListener(closeTray, func() { + cancelMainCtx() + }) + shutdownPortmaster := systray.AddMenuItem("Shut Down Portmaster", "") + go clickListener(shutdownPortmaster, func() { + _ = TriggerShutdown() + time.Sleep(1 * time.Second) + cancelMainCtx() + }) +} + +func onExit() { +} + +func triggerTrayUpdate() { + // TODO: Deduplicate triggers. + go updateTray() +} + +// updateTray update the state of the tray depending on the currently available information. +func updateTray() { + // Get current information. + spnStatus := GetSPNStatus() + failureID, failureMsg := GetFailure() + + trayLock.Lock() + defer trayLock.Unlock() + + // Select icon and status message to show. + newIconID := icons.GreenID + newStatusMsg := "Secure" + switch { + case shuttingDown.IsSet(): + newIconID = icons.RedID + newStatusMsg = "Shutting Down Portmaster" + + case restarting.IsSet(): + newIconID = icons.YellowID + newStatusMsg = "Restarting Portmaster" + + case !connected.IsSet(): + newIconID = icons.RedID + newStatusMsg = "Waiting for Portmaster Core Service" + + case failureID == FailureError: + newIconID = icons.RedID + newStatusMsg = failureMsg + + case failureID == FailureWarning: + newIconID = icons.YellowID + newStatusMsg = failureMsg + + case spnEnabled.IsSet(): + newIconID = icons.BlueID + } + + // Set icon if changed. + if newIconID != activeIconID { + activeIconID = newIconID + systray.SetIcon(icons.ColoredIcons[activeIconID]) + } + + // Set message if changed. + if newStatusMsg != activeStatusMsg { + activeStatusMsg = newStatusMsg + + // Shorten message if too long. + shortenedMsg := activeStatusMsg + if len(shortenedMsg) > shortenStatusMsgTo && strings.Contains(shortenedMsg, ". ") { + shortenedMsg = strings.SplitN(shortenedMsg, ". ", 2)[0] + } + if len(shortenedMsg) > shortenStatusMsgTo { + shortenedMsg = shortenedMsg[:shortenStatusMsgTo] + "..." + } + + menuItemStatusMsg.SetTitle("Status: " + shortenedMsg) + } + + // Set SPN status if changed. + if spnStatus != nil && activeSPNStatus != spnStatus.Status { + activeSPNStatus = spnStatus.Status + menuItemSPNStatus.SetTitle("SPN: " + strings.Title(activeSPNStatus)) // nolint:staticcheck + } + + // Set SPN switch if changed. + newSPNSwitch := "Enable SPN" + if spnEnabled.IsSet() { + newSPNSwitch = "Disable SPN" + } + if activeSPNSwitch != newSPNSwitch { + activeSPNSwitch = newSPNSwitch + menuItemSPNSwitch.SetTitle(activeSPNSwitch) + } +} + +func clickListener(item *systray.MenuItem, fn func()) { + for range item.ClickedCh { + fn() + } +} + +func launchApp() { + // build path to app + pmStartPath := filepath.Join(dataDir, "portmaster-start") + if runtime.GOOS == "windows" { + pmStartPath += ".exe" + } + + // start app + cmd := exec.Command(pmStartPath, "app", "--data", dataDir) + err := cmd.Start() + if err != nil { + log.Warningf("failed to start app: %s", err) + return + } + + // Use cmd.Wait() instead of cmd.Process.Release() to properly release its resources. + // See https://github.com/golang/go/issues/36534 + go func() { + err := cmd.Wait() + if err != nil { + log.Warningf("failed to wait/release app process: %s", err) + } + }() +} diff --git a/cmds/notifier/wintoast/notification_builder.go b/cmds/notifier/wintoast/notification_builder.go new file mode 100644 index 00000000..89eca798 --- /dev/null +++ b/cmds/notifier/wintoast/notification_builder.go @@ -0,0 +1,90 @@ +//go:build windows + +package wintoast + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +type NotificationBuilder struct { + templatePointer uintptr + lib *WinToast +} + +func newNotification(lib *WinToast, title string, message string) (*NotificationBuilder, error) { + lib.Lock() + defer lib.Unlock() + + titleUTF, _ := windows.UTF16PtrFromString(title) + messageUTF, _ := windows.UTF16PtrFromString(message) + titleP := unsafe.Pointer(titleUTF) + messageP := unsafe.Pointer(messageUTF) + + ptr, _, err := lib.createNotification.Call(uintptr(titleP), uintptr(messageP)) + if ptr == 0 { + return nil, err + } + + return &NotificationBuilder{ptr, lib}, nil +} + +func (n *NotificationBuilder) Delete() { + if n == nil { + return + } + + n.lib.Lock() + defer n.lib.Unlock() + + _, _, _ = n.lib.deleteNotification.Call(n.templatePointer) +} + +func (n *NotificationBuilder) AddButton(text string) error { + n.lib.Lock() + defer n.lib.Unlock() + textUTF, _ := windows.UTF16PtrFromString(text) + textP := unsafe.Pointer(textUTF) + + rc, _, err := n.lib.addButton.Call(n.templatePointer, uintptr(textP)) + if rc != 1 { + return err + } + return nil +} + +func (n *NotificationBuilder) SetImage(iconPath string) error { + n.lib.Lock() + defer n.lib.Unlock() + pathUTF, _ := windows.UTF16PtrFromString(iconPath) + pathP := unsafe.Pointer(pathUTF) + + rc, _, err := n.lib.setImage.Call(n.templatePointer, uintptr(pathP)) + if rc != 1 { + return err + } + return nil +} + +func (n *NotificationBuilder) SetSound(option int, path int) error { + n.lib.Lock() + defer n.lib.Unlock() + + rc, _, err := n.lib.setSound.Call(n.templatePointer, uintptr(option), uintptr(path)) + if rc != 1 { + return err + } + return nil +} + +func (n *NotificationBuilder) Show() (int64, error) { + n.lib.Lock() + defer n.lib.Unlock() + + id, _, err := n.lib.showNotification.Call(n.templatePointer) + if int64(id) == -1 { + return -1, err + } + return int64(id), nil +} diff --git a/cmds/notifier/wintoast/wintoast.go b/cmds/notifier/wintoast/wintoast.go new file mode 100644 index 00000000..5d9a3380 --- /dev/null +++ b/cmds/notifier/wintoast/wintoast.go @@ -0,0 +1,217 @@ +//go:build windows + +package wintoast + +import ( + "fmt" + "sync" + "unsafe" + + "github.com/tevino/abool" + + "golang.org/x/sys/windows" +) + +// WinNotify holds the DLL handle. +type WinToast struct { + sync.RWMutex + + dll *windows.DLL + + initialized *abool.AtomicBool + + initialize *windows.Proc + isInitialized *windows.Proc + createNotification *windows.Proc + deleteNotification *windows.Proc + addButton *windows.Proc + setImage *windows.Proc + setSound *windows.Proc + showNotification *windows.Proc + hideNotification *windows.Proc + setActivatedCallback *windows.Proc + setDismissedCallback *windows.Proc + setFailedCallback *windows.Proc +} + +func New(dllPath string) (*WinToast, error) { + if dllPath == "" { + return nil, fmt.Errorf("winnotifiy: path to dll not specified") + } + + libraryObject := &WinToast{} + libraryObject.initialized = abool.New() + + // load dll + var err error + libraryObject.dll, err = windows.LoadDLL(dllPath) + if err != nil { + return nil, fmt.Errorf("winnotifiy: failed to load notifier dll %w", err) + } + + // load functions + libraryObject.initialize, err = libraryObject.dll.FindProc("PortmasterToastInitialize") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastInitialize not found %w", err) + } + + libraryObject.isInitialized, err = libraryObject.dll.FindProc("PortmasterToastIsInitialized") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastIsInitialized not found %w", err) + } + + libraryObject.createNotification, err = libraryObject.dll.FindProc("PortmasterToastCreateNotification") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastCreateNotification not found %w", err) + } + + libraryObject.deleteNotification, err = libraryObject.dll.FindProc("PortmasterToastDeleteNotification") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastDeleteNotification not found %w", err) + } + + libraryObject.addButton, err = libraryObject.dll.FindProc("PortmasterToastAddButton") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastAddButton not found %w", err) + } + + libraryObject.setImage, err = libraryObject.dll.FindProc("PortmasterToastSetImage") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastSetImage not found %w", err) + } + + libraryObject.setSound, err = libraryObject.dll.FindProc("PortmasterToastSetSound") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastSetSound not found %w", err) + } + + libraryObject.showNotification, err = libraryObject.dll.FindProc("PortmasterToastShow") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastShow not found %w", err) + } + + libraryObject.setActivatedCallback, err = libraryObject.dll.FindProc("PortmasterToastActivatedCallback") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterActivatedCallback not found %w", err) + } + + libraryObject.setDismissedCallback, err = libraryObject.dll.FindProc("PortmasterToastDismissedCallback") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastDismissedCallback not found %w", err) + } + + libraryObject.setFailedCallback, err = libraryObject.dll.FindProc("PortmasterToastFailedCallback") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastFailedCallback not found %w", err) + } + + libraryObject.hideNotification, err = libraryObject.dll.FindProc("PortmasterToastHide") + if err != nil { + return nil, fmt.Errorf("winnotifiy: PortmasterToastHide not found %w", err) + } + + return libraryObject, nil +} + +func (lib *WinToast) Initialize(appName, aumi, originalShortcutPath string) error { + if lib == nil { + return fmt.Errorf("wintoast: lib object was nil") + } + + lib.Lock() + defer lib.Unlock() + + // Initialize all necessary string for the notification meta data + appNameUTF, _ := windows.UTF16PtrFromString(appName) + aumiUTF, _ := windows.UTF16PtrFromString(aumi) + linkUTF, _ := windows.UTF16PtrFromString(originalShortcutPath) + + // They are needed as unsafe pointers + appNameP := unsafe.Pointer(appNameUTF) + aumiP := unsafe.Pointer(aumiUTF) + linkP := unsafe.Pointer(linkUTF) + + // Initialize notifications + rc, _, err := lib.initialize.Call(uintptr(appNameP), uintptr(aumiP), uintptr(linkP)) + if rc != 0 { + return fmt.Errorf("wintoast: failed to initialize library rc = %d, %w", rc, err) + } + + // Check if if the initialization was successfully + rc, _, _ = lib.isInitialized.Call() + if rc == 1 { + lib.initialized.Set() + } else { + return fmt.Errorf("wintoast: initialized flag was not set: rc = %d", rc) + } + + return nil +} + +func (lib *WinToast) SetCallbacks(activated func(id int64, actionIndex int32), dismissed func(id int64, reason int32), failed func(id int64, reason int32)) error { + if lib == nil { + return fmt.Errorf("wintoast: lib object was nil") + } + + if lib.initialized.IsNotSet() { + return fmt.Errorf("winnotifiy: library not initialized") + } + + // Initialize notification activated callback + callback := windows.NewCallback(func(id int64, actionIndex int32) uint64 { + activated(id, actionIndex) + return 0 + }) + rc, _, err := lib.setActivatedCallback.Call(callback) + if rc != 1 { + return fmt.Errorf("winnotifiy: failed to initialize activated callback %w", err) + } + + // Initialize notification dismissed callback + callback = windows.NewCallback(func(id int64, actionIndex int32) uint64 { + dismissed(id, actionIndex) + return 0 + }) + rc, _, err = lib.setDismissedCallback.Call(callback) + if rc != 1 { + return fmt.Errorf("winnotifiy: failed to initialize dismissed callback %w", err) + } + + // Initialize notification failed callback + callback = windows.NewCallback(func(id int64, actionIndex int32) uint64 { + failed(id, actionIndex) + return 0 + }) + rc, _, err = lib.setFailedCallback.Call(callback) + if rc != 1 { + return fmt.Errorf("winnotifiy: failed to initialize failed callback %s", err) + } + + return nil +} + +// NewNotification starts a creation of new notification. NotificationBuilder.Delete should allays be called when done using the object or there will be memory leeks +func (lib *WinToast) NewNotification(title string, content string) (*NotificationBuilder, error) { + if lib == nil { + return nil, fmt.Errorf("wintoast: lib object was nil") + } + return newNotification(lib, title, content) +} + +// HideNotification hides notification +func (lib *WinToast) HideNotification(id int64) error { + if lib == nil { + return fmt.Errorf("wintoast: lib object was nil") + } + + lib.Lock() + defer lib.Unlock() + + rc, _, _ := lib.hideNotification.Call(uintptr(id)) + + if rc != 1 { + return fmt.Errorf("wintoast: failed to hide notification %d", id) + } + + return nil +} diff --git a/cmds/observation-hub/.gitignore b/cmds/observation-hub/.gitignore new file mode 100644 index 00000000..f1b57325 --- /dev/null +++ b/cmds/observation-hub/.gitignore @@ -0,0 +1,3 @@ +# Compiled binaries +observation-hub +observation-hub.exe diff --git a/cmds/observation-hub/Dockerfile b/cmds/observation-hub/Dockerfile new file mode 100644 index 00000000..0ff78cb9 --- /dev/null +++ b/cmds/observation-hub/Dockerfile @@ -0,0 +1,38 @@ +# Docker Image for Observation Hub + +# Important: +# You need to build this from the repo root! +# Run: docker build -f cmds/observation-hub/Dockerfile -t safing/observation-hub:latest . +# Check With: docker run -ti --rm safing/observation-hub:latest --help + +# golang 1.21 linux/amd64 on debian bookworm +# https://github.com/docker-library/golang/blob/master/1.21/bookworm/Dockerfile +FROM golang:1.21-bookworm as builder + +# Ensure ca-certficates are up to date +RUN update-ca-certificates + +# Install dependencies +WORKDIR $GOPATH/src/github.com/safing/portmaster/spn +COPY go.mod . +COPY go.sum . +ENV GO111MODULE=on +RUN go mod download +RUN go mod verify + +# Copy source code +COPY . . + +# Build the static binary +RUN cd cmds/observation-hub && \ +CGO_ENABLED=0 ./build -o /go/bin/observation-hub + +# Use static image +# https://github.com/GoogleContainerTools/distroless +FROM gcr.io/distroless/static-debian12 + +# Copy our static executable +COPY --from=builder --chmod=0755 /go/bin/observation-hub /go/bin/observation-hub + +# Run the observation-hub binary. +ENTRYPOINT ["/go/bin/observation-hub"] diff --git a/cmds/observation-hub/apprise.go b/cmds/observation-hub/apprise.go new file mode 100644 index 00000000..c7df3c19 --- /dev/null +++ b/cmds/observation-hub/apprise.go @@ -0,0 +1,257 @@ +package main + +import ( + "bytes" + "crypto/tls" + _ "embed" + "errors" + "flag" + "fmt" + "net/http" + "strings" + "text/template" + "time" + + "github.com/safing/portbase/apprise" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/service/intel/geoip" +) + +var ( + appriseModule *modules.Module + appriseNotifier *apprise.Notifier + + appriseURL string + appriseTag string + appriseClientCert string + appriseClientKey string + appriseGreet bool +) + +func init() { + appriseModule = modules.Register("apprise", nil, startApprise, nil) + + flag.StringVar(&appriseURL, "apprise-url", "", "set the apprise URL to enable notifications via apprise") + flag.StringVar(&appriseTag, "apprise-tag", "", "set the apprise tag(s) according to their docs") + flag.StringVar(&appriseClientCert, "apprise-client-cert", "", "set the apprise client certificate") + flag.StringVar(&appriseClientKey, "apprise-client-key", "", "set the apprise client key") + flag.BoolVar(&appriseGreet, "apprise-greet", false, "send a greeting message to apprise on start") +} + +func startApprise() error { + // Check if apprise should be configured. + if appriseURL == "" { + return nil + } + // Check if there is a tag. + if appriseTag == "" { + return errors.New("an apprise tag is required") + } + + // Create notifier. + appriseNotifier = &apprise.Notifier{ + URL: appriseURL, + DefaultType: apprise.TypeInfo, + DefaultTag: appriseTag, + DefaultFormat: apprise.FormatMarkdown, + AllowUntagged: false, + } + + if appriseClientCert != "" || appriseClientKey != "" { + // Load client cert from disk. + cert, err := tls.LoadX509KeyPair(appriseClientCert, appriseClientKey) + if err != nil { + return fmt.Errorf("failed to load client cert/key: %w", err) + } + + // Set client cert in http client. + appriseNotifier.SetClient(&http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }, + }, + Timeout: 10 * time.Second, + }) + } + + if appriseGreet { + err := appriseNotifier.Send(appriseModule.Ctx, &apprise.Message{ + Title: "👋 Observation Hub Reporting In", + Body: "I am the Observation Hub. I am connected to the SPN and watch out for it. I will report notable changes to the network here.", + }) + if err != nil { + log.Warningf("apprise: failed to send test message: %s", err) + } else { + log.Info("apprise: sent greeting message") + } + } + + return nil +} + +func reportToApprise(change *observedChange) (errs error) { + // Check if configured. + if appriseNotifier == nil { + return nil + } + +handleTag: + for _, tag := range strings.Split(appriseNotifier.DefaultTag, ",") { + // Check if we are shutting down. + if appriseModule.IsStopping() { + return nil + } + + // Render notification based on tag / destination. + buf := &bytes.Buffer{} + switch { + case strings.HasPrefix(tag, "matrix-"): + if err := templates.ExecuteTemplate(buf, "matrix-notification", change); err != nil { + return fmt.Errorf("failed to render notification: %w", err) + } + + case strings.HasPrefix(tag, "discord-"): + if err := templates.ExecuteTemplate(buf, "discord-notification", change); err != nil { + return fmt.Errorf("failed to render notification: %w", err) + } + + default: + // Use matrix notification template as default for now. + if err := templates.ExecuteTemplate(buf, "matrix-notification", change); err != nil { + return fmt.Errorf("failed to render notification: %w", err) + } + } + + // Send notification to apprise. + var err error + for i := 0; i < 3; i++ { + // Try three times. + err = appriseNotifier.Send(appriseModule.Ctx, &apprise.Message{ + Body: buf.String(), + Tag: tag, + }) + if err == nil { + continue handleTag + } + // Wait for 5 seconds, then try again. + time.Sleep(5 * time.Second) + } + // Add error to errors. + if err != nil { + errs = errors.Join(errs, fmt.Errorf("| failed to send: %w", err)) + } + } + + return errs +} + +// var ( +// entityTemplate = template.Must(template.New("entity").Parse( +// `Entity: {{ . }} +// {{ .IP }} [{{ .ASN }} - {{ .ASOrg }}] +// `, +// )) + +// // {{ with .GetCountryInfo -}} +// // {{ .Name }} ({{ .Code }}) +// // {{- end }} + +// matrixTemplate = template.Must(template.New("matrix observer notification").Parse( +// `{{ .Title }} +// {{ if .Summary }} +// Details: +// {{ .Summary }} + +// Note: Changes were registered at {{ .UpdateTime }} and were possibly merged. +// {{ end }} + +// {{ template "entity" .UpdatedPin.EntityV4 }} + +// Hub Info: +// Test: {{ .UpdatedPin.EntityV4 }} +// {{ template "entity" .UpdatedPin.EntityV4 }} +// {{ template "entity" .UpdatedPin.EntityV6 }} +// `, +// )) + +// discordTemplate = template.Must(template.New("discord observer notification").Parse( +// ``, +// )) + +// defaultTemplate = template.Must(template.New("default observer notification").Parse( +// ``, +// )) +// ) + +var ( + //go:embed notifications.tmpl + templateFile string + templates = template.Must(template.New("notifications").Funcs( + template.FuncMap{ + "joinStrings": joinStrings, + "textBlock": textBlock, + "getCountryInfo": getCountryInfo, + }, + ).Parse(templateFile)) +) + +func joinStrings(slice []string, sep string) string { + return strings.Join(slice, sep) +} + +func textBlock(block, addPrefix, addSuffix string) string { + // Trim whitespaces. + block = strings.TrimSpace(block) + + // Prepend and append string for every line. + lines := strings.Split(block, "\n") + for i, line := range lines { + lines[i] = addPrefix + line + addSuffix + } + + // Return as block. + return strings.Join(lines, "\n") +} + +func getCountryInfo(code string) geoip.CountryInfo { + // Get the country info directly instead of via the entity location, + // so it also works in test without the geoip module. + return geoip.GetCountryInfo(code) +} + +// func init() { +// templates = template.Must(template.New(templateFile).Parse(templateFile)) + +// nt, err := templates.New("entity").Parse( +// `Entity: {{ . }} +// {{ .IP }} [{{ .ASN }} - {{ .ASOrg }}] +// `, +// ) +// if err != nil { +// panic(err) +// } +// templates.AddParseTree(nt.Tree) + +// if _, err := templates.New("matrix-notification").Parse( +// `{{ .Title }} +// {{ if .Summary }} +// Details: +// {{ .Summary }} + +// Note: Changes were registered at {{ .UpdateTime }} and were possibly merged. +// {{ end }} + +// {{ template "entity" .UpdatedPin.EntityV4 }} + +// Hub Info: +// Test: {{ .UpdatedPin.EntityV4 }} +// {{ template "entity" .UpdatedPin.EntityV4 }} +// {{ template "entity" .UpdatedPin.EntityV6 }} +// `, +// ); err != nil { +// panic(err) +// } +// } diff --git a/cmds/observation-hub/apprise_test.go b/cmds/observation-hub/apprise_test.go new file mode 100644 index 00000000..e0397858 --- /dev/null +++ b/cmds/observation-hub/apprise_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "bytes" + "fmt" + "net" + "testing" + "time" + + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/navigator" +) + +var observedTestChange = &observedChange{ + Title: "Hub Changed: fogos (8uLe-zUkC)", + Summary: `ConnectedTo.ZwqBAzGqifBAFKFW1GQijNM18pi7BnWH34GyKBF7KB5fC5.HubID removed ZwqBAzGqifBAFKFW1GQijNM18pi7BnWH34GyKBF7KB5fC5 + ConnectedTo.ZwqBAzGqifBAFKFW1GQijNM18pi7BnWH34GyKBF7KB5fC5.Capacity removed 3403661 + ConnectedTo.ZwqBAzGqifBAFKFW1GQijNM18pi7BnWH34GyKBF7KB5fC5.Latency removed 252.350006ms`, + UpdatedPin: &navigator.PinExport{ + ID: "Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC", + Name: "fogos", + Map: "main", + FirstSeen: time.Now(), + EntityV4: &intel.Entity{ + IP: net.IPv4(138, 201, 140, 70), + IPScope: netutils.Global, + Country: "DE", + ASN: 24940, + ASOrg: "Hetzner Online GmbH", + }, + States: []string{"HasRequiredInfo", "Reachable", "Active", "Trusted"}, + VerifiedOwner: "Safing", + HopDistance: 3, + SessionActive: false, + Info: &hub.Announcement{ + ID: "Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC", + Timestamp: 1677682008, + Name: "fogos", + Group: "Safing", + ContactAddress: "abuse@safing.io", + ContactService: "email", + Hosters: []string{"Hetzner"}, + Datacenter: "DE-Hetzner-FSN", + IPv4: net.IPv4(138, 201, 140, 70), + IPv6: net.ParseIP("2a01:4f8:172:3753::2"), + Transports: []string{"tcp:17", "tcp:17017"}, + Entry: []string{}, + Exit: []string{"- * TCP/25"}, + }, + Status: &hub.Status{ + Timestamp: 1694180778, + Version: "0.6.19 ", + }, + }, + UpdateTime: time.Now(), +} + +func TestNotificationTemplate(t *testing.T) { + t.Parallel() + + fmt.Println("==========\nFound templates:") + for _, tpl := range templates.Templates() { + fmt.Println(tpl.Name()) + } + fmt.Println("") + + fmt.Println("\n\n==========\nMatrix template:") + matrixOutput := &bytes.Buffer{} + err := templates.ExecuteTemplate(matrixOutput, "matrix-notification", observedTestChange) + if err != nil { + t.Errorf("failed to render matrix template: %s", err) + } + fmt.Println(matrixOutput.String()) + + fmt.Println("\n\n==========\nDiscord template:") + discordOutput := &bytes.Buffer{} + err = templates.ExecuteTemplate(discordOutput, "discord-notification", observedTestChange) + if err != nil { + t.Errorf("failed to render discord template: %s", err) + } + fmt.Println(discordOutput.String()) +} diff --git a/cmds/observation-hub/build b/cmds/observation-hub/build new file mode 100755 index 00000000..055874ef --- /dev/null +++ b/cmds/observation-hub/build @@ -0,0 +1,60 @@ +#!/bin/bash + +# get build data +if [[ "$BUILD_COMMIT" == "" ]]; then + BUILD_COMMIT=$(git describe --all --long --abbrev=99 --dirty 2>/dev/null) +fi +if [[ "$BUILD_USER" == "" ]]; then + BUILD_USER=$(id -un) +fi +if [[ "$BUILD_HOST" == "" ]]; then + BUILD_HOST=$(hostname -f) +fi +if [[ "$BUILD_DATE" == "" ]]; then + BUILD_DATE=$(date +%d.%m.%Y) +fi +if [[ "$BUILD_SOURCE" == "" ]]; then + BUILD_SOURCE=$(git remote -v | grep origin | cut -f2 | cut -d" " -f1 | head -n 1) +fi +if [[ "$BUILD_SOURCE" == "" ]]; then + BUILD_SOURCE=$(git remote -v | cut -f2 | cut -d" " -f1 | head -n 1) +fi +BUILD_BUILDOPTIONS=$(echo $* | sed "s/ /§/g") + +# check +if [[ "$BUILD_COMMIT" == "" ]]; then + echo "could not automatically determine BUILD_COMMIT, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_USER" == "" ]]; then + echo "could not automatically determine BUILD_USER, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_HOST" == "" ]]; then + echo "could not automatically determine BUILD_HOST, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_DATE" == "" ]]; then + echo "could not automatically determine BUILD_DATE, please supply manually as environment variable." + exit 1 +fi +if [[ "$BUILD_SOURCE" == "" ]]; then + echo "could not automatically determine BUILD_SOURCE, please supply manually as environment variable." + exit 1 +fi + +# set build options +export CGO_ENABLED=0 +if [[ $1 == "dev" ]]; then + shift + export CGO_ENABLED=1 + DEV="-race" +fi + +echo "Please notice, that this build script includes metadata into the build." +echo "This information is useful for debugging and license compliance." +echo "Run the compiled binary with the -version flag to see the information included." + +# build +BUILD_PATH="github.com/safing/portbase/info" +go build $DEV -ldflags "-X ${BUILD_PATH}.commit=${BUILD_COMMIT} -X ${BUILD_PATH}.buildOptions=${BUILD_BUILDOPTIONS} -X ${BUILD_PATH}.buildUser=${BUILD_USER} -X ${BUILD_PATH}.buildHost=${BUILD_HOST} -X ${BUILD_PATH}.buildDate=${BUILD_DATE} -X ${BUILD_PATH}.buildSource=${BUILD_SOURCE}" $* diff --git a/cmds/observation-hub/main.go b/cmds/observation-hub/main.go new file mode 100644 index 00000000..c69786c9 --- /dev/null +++ b/cmds/observation-hub/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "os" + "runtime" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/info" + "github.com/safing/portbase/metrics" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/run" + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/service/updates/helper" + "github.com/safing/portmaster/spn/captain" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/sluice" +) + +func main() { + info.Set("SPN Observation Hub", "0.7.1", "GPLv3") + + // Configure metrics. + _ = metrics.SetNamespace("observer") + + // Configure user agent. + updates.UserAgent = fmt.Sprintf("SPN Observation Hub (%s %s)", runtime.GOOS, runtime.GOARCH) + helper.IntelOnly() + + // Configure SPN mode. + conf.EnableClient(true) + conf.EnablePublicHub(false) + captain.DisableAccount = true + + // Disable unneeded listeners. + sluice.EnableListener = false + api.EnableServer = false + + // Disable module management, as we want to start all modules. + modules.DisableModuleManagement() + + // Start. + os.Exit(run.Run()) +} diff --git a/cmds/observation-hub/notifications.tmpl b/cmds/observation-hub/notifications.tmpl new file mode 100644 index 00000000..8a25175f --- /dev/null +++ b/cmds/observation-hub/notifications.tmpl @@ -0,0 +1,75 @@ +{{ define "entity" -}} + {{ .IP }} [AS{{ .ASN }} - {{ .ASOrg }}] in {{ if .Country }} + {{- with getCountryInfo .Country -}} + {{ .Name }} ({{ .Code }}; Region {{ .Continent.Region }}) + {{- end }} + {{- end }} +{{- end }} + +{{ define "matrix-notification" -}} +### 🌍 {{ .Title }}{{ if .Summary }} + +{{ textBlock .Summary "" " " }} +{{ end }} + +> Note: Changes were registered at {{ .UpdateTime.UTC.Format "15:04:05 02.01.2006 MST" }} and were possibly merged. + +##### Hub Info + +> Name: {{ .UpdatedPin.Name }} +> ID: {{ .UpdatedPin.ID }} +> IPv4: {{ if .UpdatedPin.EntityV4 }}{{ template "entity" .UpdatedPin.EntityV4 }}{{ end }} +> IPv6: {{ if .UpdatedPin.EntityV6 }}{{ template "entity" .UpdatedPin.EntityV6 }}{{ end }} +> Version: {{ .UpdatedPin.Status.Version }} +> States: {{ joinStrings .UpdatedPin.States ", " }} +> Status: {{ len .UpdatedPin.Status.Lanes }} Lanes, {{ len .UpdatedPin.Status.Keys }} Keys, {{ .UpdatedPin.Status.Load }} Load +> Verified Owner: {{ .UpdatedPin.VerifiedOwner }} +> Transports: {{ joinStrings .UpdatedPin.Info.Transports ", " }} +> Entry: {{ joinStrings .UpdatedPin.Info.Entry ", " }} +> Exit: {{ joinStrings .UpdatedPin.Info.Exit ", " }} +> Relations: {{ if .UpdatedPin.Info.Group -}} +Group={{ .UpdatedPin.Info.Group }} {{ end }} + +{{- if .UpdatedPin.Info.Datacenter -}} +Datacenter={{ .UpdatedPin.Info.Datacenter }} {{ end }} + +{{- if .UpdatedPin.Info.Hosters -}} +Hosters={{ joinStrings .UpdatedPin.Info.Hosters ";" }} {{ end }} + +{{- if .UpdatedPin.Info.ContactAddress -}} +Contact= {{ .UpdatedPin.Info.ContactAddress }}{{ if .UpdatedPin.Info.ContactService }} via {{ .UpdatedPin.Info.ContactService }}{{ end }}{{ end }} + +{{- end }} + +{{ define "discord-notification" -}} +# 🌍 {{ .Title }}{{ if .Summary }} + +{{ .Summary }} +{{- end }} + +##### Note: Changes were registered at {{ .UpdateTime.UTC.Format "15:04:05 02.01.2006 MST" }} and were possibly merged. - Hub Info: + +Name: {{ .UpdatedPin.Name }} +ID: {{ .UpdatedPin.ID }} +IPv4: {{ if .UpdatedPin.EntityV4 }}{{ template "entity" .UpdatedPin.EntityV4 }}{{ end }} +IPv6: {{ if .UpdatedPin.EntityV6 }}{{ template "entity" .UpdatedPin.EntityV6 }}{{ end }} +Version: {{ .UpdatedPin.Status.Version }} +States: {{ joinStrings .UpdatedPin.States ", " }} +Status: {{ len .UpdatedPin.Status.Lanes }} Lanes, {{ len .UpdatedPin.Status.Keys }} Keys, {{ .UpdatedPin.Status.Load }} Load +Verified Owner: {{ .UpdatedPin.VerifiedOwner }} +Transports: {{ joinStrings .UpdatedPin.Info.Transports ", " }} +Entry: {{ joinStrings .UpdatedPin.Info.Entry ", " }} +Exit: {{ joinStrings .UpdatedPin.Info.Exit ", " }} +Relations: {{ if .UpdatedPin.Info.Group -}} +Group={{ .UpdatedPin.Info.Group }} {{ end }} + +{{- if .UpdatedPin.Info.Datacenter -}} +Datacenter={{ .UpdatedPin.Info.Datacenter }} {{ end }} + +{{- if .UpdatedPin.Info.Hosters -}} +Hosters={{ joinStrings .UpdatedPin.Info.Hosters ";" }} {{ end }} + +{{- if .UpdatedPin.Info.ContactAddress -}} +Contact= {{ .UpdatedPin.Info.ContactAddress }}{{ if .UpdatedPin.Info.ContactService }} via {{ .UpdatedPin.Info.ContactService }}{{ end }}{{ end }} + +{{- end }} diff --git a/cmds/observation-hub/observe.go b/cmds/observation-hub/observe.go new file mode 100644 index 00000000..371b8692 --- /dev/null +++ b/cmds/observation-hub/observe.go @@ -0,0 +1,407 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "path" + "strings" + "time" + + diff "github.com/r3labs/diff/v3" + "golang.org/x/exp/slices" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/captain" + "github.com/safing/portmaster/spn/navigator" +) + +var ( + observerModule *modules.Module + + db = database.NewInterface(&database.Options{ + Local: true, + Internal: true, + }) + + reportAllChanges bool + + errNoChanges = errors.New("no changes") + + reportingDelayFlag string + reportingDelay = 10 * time.Minute +) + +func init() { + observerModule = modules.Register("observer", prepObserver, startObserver, nil, "captain", "apprise") + + flag.BoolVar(&reportAllChanges, "report-all-changes", false, "report all changes, no just interesting ones") + flag.StringVar(&reportingDelayFlag, "reporting-delay", "10m", "delay reports to summarize changes") +} + +func prepObserver() error { + if reportingDelayFlag != "" { + duration, err := time.ParseDuration(reportingDelayFlag) + if err != nil { + return fmt.Errorf("failed to parse reporting-delay: %w", err) + } + reportingDelay = duration + } + + return nil +} + +func startObserver() error { + observerModule.StartServiceWorker("observer", 0, observerWorker) + + return nil +} + +type observedPin struct { + previous *navigator.PinExport + latest *navigator.PinExport + + lastUpdate time.Time + lastUpdateReported bool +} + +type observedChange struct { + Title string + Summary string + + UpdatedPin *navigator.PinExport + UpdateTime time.Time + + SPNStatus *captain.SPNStatus +} + +func observerWorker(ctx context.Context) error { + log.Info("observer: starting") + defer log.Info("observer: stopped") + + // Subscribe to SPN status. + statusSub, err := db.Subscribe(query.New("runtime:spn/status")) + if err != nil { + return fmt.Errorf("failed to subscribe to spn status: %w", err) + } + defer statusSub.Cancel() //nolint:errcheck + + // Get latest status. + latestStatus := captain.GetSPNStatus() + + // Step 1: Wait for SPN to connect, if needed. + if latestStatus.Status != captain.StatusConnected { + log.Info("observer: waiting for SPN to connect") + waitForConnect: + for { + select { + case r := <-statusSub.Feed: + if r == nil { + return errors.New("status feed ended") + } + + statusUpdate, ok := r.(*captain.SPNStatus) + switch { + case !ok: + log.Warningf("observer: received invalid SPN status: %s", r) + case statusUpdate.Status == captain.StatusFailed: + log.Warningf("observer: SPN failed to connect") + case statusUpdate.Status == captain.StatusConnected: + break waitForConnect + } + case <-ctx.Done(): + return nil + } + } + } + + // Wait for one second for the navigator to settle things. + log.Info("observer: connected to network, waiting for navigator") + time.Sleep(1 * time.Second) + + // Step 2: Get current state. + mapQuery := query.New("map:main/") + q, err := db.Query(mapQuery) + if err != nil { + return fmt.Errorf("failed to start map query: %w", err) + } + defer q.Cancel() + + // Put all current pins in a map. + observedPins := make(map[string]*observedPin) +query: + for { + select { + case r := <-q.Next: + // Check if we are done. + if r == nil { + break query + } + // Add all pins to seen pins. + if pin, ok := r.(*navigator.PinExport); ok { + observedPins[pin.ID] = &observedPin{ + previous: pin, + latest: pin, + } + } else { + log.Warningf("observer: received invalid pin export: %s", r) + } + case <-ctx.Done(): + return nil + } + } + if q.Err() != nil { + return fmt.Errorf("failed to finish map query: %w", q.Err()) + } + + // Step 3: Monitor for changes. + sub, err := db.Subscribe(mapQuery) + if err != nil { + return fmt.Errorf("failed to start map sub: %w", err) + } + defer sub.Cancel() //nolint:errcheck + + // Start ticker for checking for changes. + reportChangesTicker := time.NewTicker(10 * time.Second) + defer reportChangesTicker.Stop() + + log.Info("observer: listening for hub changes") + for { + select { + case <-ctx.Done(): + return nil + + case r := <-statusSub.Feed: + // Keep SPN connection status up to date. + if r == nil { + return errors.New("status feed ended") + } + if statusUpdate, ok := r.(*captain.SPNStatus); ok { + latestStatus = statusUpdate + log.Infof("observer: SPN status is now %s", statusUpdate.Status) + } else { + log.Warningf("observer: received invalid pin export: %s", r) + } + + case r := <-sub.Feed: + // Save all observed pins. + switch { + case r == nil: + return errors.New("pin feed ended") + case r.Meta().IsDeleted(): + delete(observedPins, path.Base(r.DatabaseKey())) + default: + if pin, ok := r.(*navigator.PinExport); ok { + existingObservedPin, ok := observedPins[pin.ID] + if ok { + // Update previously observed Hub. + existingObservedPin.latest = pin + existingObservedPin.lastUpdate = time.Now() + existingObservedPin.lastUpdateReported = false + } else { + // Add new Hub. + observedPins[pin.ID] = &observedPin{ + latest: pin, + lastUpdate: time.Now(), + } + } + } else { + log.Warningf("observer: received invalid pin export: %s", r) + } + } + + case <-reportChangesTicker.C: + // Report changed pins. + + for _, observedPin := range observedPins { + // Check if context was canceled. + select { + case <-ctx.Done(): + return nil + default: + } + + switch { + case observedPin.lastUpdateReported: + // Change already reported. + case time.Since(observedPin.lastUpdate) < reportingDelay: + // Only report changes if older than the configured delay. + default: + // Format and report. + title, changes, err := formatPinChanges(observedPin.previous, observedPin.latest) + if err != nil { + if !errors.Is(err, errNoChanges) { + log.Warningf("observer: failed to format pin changes: %s", err) + } + } else { + // Report changes. + reportChanges(&observedChange{ + Title: title, + Summary: changes, + UpdatedPin: observedPin.latest, + UpdateTime: observedPin.lastUpdate, + SPNStatus: latestStatus, + }) + } + + // Update observed pin. + observedPin.previous = observedPin.latest + observedPin.lastUpdateReported = true + } + } + } + } +} + +func reportChanges(change *observedChange) { + // Log changes. + log.Infof("observer:\n%s\n%s", change.Title, change.Summary) + + // Report via Apprise. + err := reportToApprise(change) + if err != nil { + log.Warningf("observer: failed to report changes to apprise: %s", err) + } +} + +var ( + ignoreChangesIn = []string{ + "ConnectedTo", + "HopDistance", + "Info.entryPolicy", // Alternatively, ignore "Info.Entry" + "Info.exitPolicy", // Alternatively, ignore "Info.Exit" + "Info.parsedTransports", + "Info.Timestamp", + "SessionActive", + "Status.Keys", + "Status.Lanes", + "Status.Load", + "Status.Timestamp", + } + + ignoreStates = []string{ + "IsHomeHub", + "Failing", + } +) + +func ignoreChange(path string) bool { + if reportAllChanges { + return false + } + + for _, pathPrefix := range ignoreChangesIn { + if strings.HasPrefix(path, pathPrefix) { + return true + } + } + return false +} + +func formatPinChanges(from, to *navigator.PinExport) (title, changes string, err error) { + // Return immediately if pin is new. + if from == nil { + return fmt.Sprintf("New Hub: %s", makeHubName(to.Name, to.ID)), "", nil + } + + // Find notable changes. + changelog, err := diff.Diff(from, to) + if err != nil { + return "", "", fmt.Errorf("failed to diff: %w", err) + } + if len(changelog) > 0 { + // Build changelog message. + changes := make([]string, 0, len(changelog)) + for _, change := range changelog { + // Create path to changed field. + fullPath := strings.Join(change.Path, ".") + + // Check if this path should be ignored. + if ignoreChange(fullPath) { + continue + } + + // Add to reportable changes. + changeMsg := formatChange(change, fullPath) + if changeMsg != "" { + changes = append(changes, changeMsg) + } + } + + // Log the changes, if there are any left. + if len(changes) > 0 { + return fmt.Sprintf("Hub Changed: %s", makeHubName(to.Name, to.ID)), + strings.Join(changes, "\n"), + nil + } + } + + return "", "", errNoChanges +} + +func formatChange(change diff.Change, fullPath string) string { + switch { + case strings.HasPrefix(fullPath, "States"): + switch change.Type { + case diff.CREATE: + return formatState(fmt.Sprintf("%v", change.To), true) + case diff.UPDATE: + a := formatState(fmt.Sprintf("%v", change.To), true) + b := formatState(fmt.Sprintf("%v", change.From), false) + switch { + case a != "" && b != "": + return a + "\n" + b + case a != "": + return a + case b != "": + return b + } + case diff.DELETE: + return formatState(fmt.Sprintf("%v", change.From), false) + } + + default: + switch change.Type { + case diff.CREATE: + return fmt.Sprintf("%s added %v", fullPath, change.To) + case diff.UPDATE: + return fmt.Sprintf("%s changed from %v to %v", fullPath, change.From, change.To) + case diff.DELETE: + return fmt.Sprintf("%s removed %v", fullPath, change.From) + } + } + + return "" +} + +func formatState(name string, isSet bool) string { + // Check if state should be ignored. + if !reportAllChanges && slices.Contains[[]string, string](ignoreStates, name) { + return "" + } + + if isSet { + return fmt.Sprintf("State is %v", name) + } + return fmt.Sprintf("State is NOT %v", name) +} + +func makeHubName(name, id string) string { + shortenedID := id[len(id)-8:len(id)-4] + + "-" + + id[len(id)-4:] + + // Be more careful, as the Hub name is user input. + switch { + case name == "": + return shortenedID + case len(name) > 16: + return fmt.Sprintf("%s (%s)", name[:16], shortenedID) + default: + return fmt.Sprintf("%s (%s)", name, shortenedID) + } +} diff --git a/cmds/portmaster-core/build b/cmds/portmaster-core/build index 355e661e..6f6bb113 100755 --- a/cmds/portmaster-core/build +++ b/cmds/portmaster-core/build @@ -1,60 +1,14 @@ #!/bin/bash -# get build data -if [[ "$BUILD_COMMIT" == "" ]]; then - BUILD_COMMIT=$(git describe --all --long --abbrev=99 --dirty 2>/dev/null) -fi -if [[ "$BUILD_USER" == "" ]]; then - BUILD_USER=$(id -un) -fi -if [[ "$BUILD_HOST" == "" ]]; then - BUILD_HOST=$(hostname -f) -fi -if [[ "$BUILD_DATE" == "" ]]; then - BUILD_DATE=$(date +%d.%m.%Y) -fi -if [[ "$BUILD_SOURCE" == "" ]]; then - BUILD_SOURCE=$(git remote -v | grep origin | cut -f2 | cut -d" " -f1 | head -n 1) -fi -if [[ "$BUILD_SOURCE" == "" ]]; then - BUILD_SOURCE=$(git remote -v | cut -f2 | cut -d" " -f1 | head -n 1) -fi -BUILD_BUILDOPTIONS=$(echo $* | sed "s/ /§/g") +# Gather build metadata. +VERSION="$(git tag --points-at)" || true +test -z "$VERSION" && DEV_VERSION="$(git describe --tags --first-parent --abbrev=0)" || true +test -n "$DEV_VERSION" && VERSION="${DEV_VERSION}_dev_build" +test -z "$VERSION" && VERSION="dev_build" +SOURCE=$( ( git remote -v | cut -f2 | cut -d" " -f1 | head -n 1 ) || echo "unknown" ) +BUILD_TIME=$(date -u "+%Y-%m-%dT%H:%M:%SZ" || echo "unknown") -# check -if [[ "$BUILD_COMMIT" == "" ]]; then - echo "could not automatically determine BUILD_COMMIT, please supply manually as environment variable." - exit 1 -fi -if [[ "$BUILD_USER" == "" ]]; then - echo "could not automatically determine BUILD_USER, please supply manually as environment variable." - exit 1 -fi -if [[ "$BUILD_HOST" == "" ]]; then - echo "could not automatically determine BUILD_HOST, please supply manually as environment variable." - exit 1 -fi -if [[ "$BUILD_DATE" == "" ]]; then - echo "could not automatically determine BUILD_DATE, please supply manually as environment variable." - exit 1 -fi -if [[ "$BUILD_SOURCE" == "" ]]; then - echo "could not automatically determine BUILD_SOURCE, please supply manually as environment variable." - exit 1 -fi - -echo "Please notice, that this build script includes metadata into the build." -echo "This information is useful for debugging and license compliance." -echo "Run the compiled binary with the -version flag to see the information included." - -if [[ $1 == "dev" ]]; then - shift - export CGO_ENABLED=1 - DEV="-race" -else - export CGO_ENABLED=0 -fi - -# build +# Build +export CGO_ENABLED=0 BUILD_PATH="github.com/safing/portbase/info" -go build $DEV -ldflags "-X ${BUILD_PATH}.commit=${BUILD_COMMIT} -X ${BUILD_PATH}.buildOptions=${BUILD_BUILDOPTIONS} -X ${BUILD_PATH}.buildUser=${BUILD_USER} -X ${BUILD_PATH}.buildHost=${BUILD_HOST} -X ${BUILD_PATH}.buildDate=${BUILD_DATE} -X ${BUILD_PATH}.buildSource=${BUILD_SOURCE}" "$@" +go build -ldflags "-X github.com/safing/portbase/info.version=${VERSION} -X github.com/safing/portbase/info.buildSource=${SOURCE} -X github.com/safing/portbase/info.buildTime=${BUILD_TIME}" "$@" diff --git a/cmds/portmaster-core/main.go b/cmds/portmaster-core/main.go index 517f604a..687dcf4e 100644 --- a/cmds/portmaster-core/main.go +++ b/cmds/portmaster-core/main.go @@ -10,21 +10,21 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/metrics" "github.com/safing/portbase/run" - "github.com/safing/portmaster/updates" - "github.com/safing/spn/conf" + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/spn/conf" // Include packages here. _ "github.com/safing/portbase/modules/subsystems" - _ "github.com/safing/portmaster/core" - _ "github.com/safing/portmaster/firewall" - _ "github.com/safing/portmaster/nameserver" - _ "github.com/safing/portmaster/ui" - _ "github.com/safing/spn/captain" + _ "github.com/safing/portmaster/service/core" + _ "github.com/safing/portmaster/service/firewall" + _ "github.com/safing/portmaster/service/nameserver" + _ "github.com/safing/portmaster/service/ui" + _ "github.com/safing/portmaster/spn/captain" ) func main() { // set information - info.Set("Portmaster", "1.6.5", "AGPLv3", true) + info.Set("Portmaster", "", "GPLv3") // Set default log level. log.SetLogLevel(log.WarningLevel) diff --git a/cmds/portmaster-start/lock.go b/cmds/portmaster-start/lock.go index 0db86606..0526084c 100644 --- a/cmds/portmaster-start/lock.go +++ b/cmds/portmaster-start/lock.go @@ -79,7 +79,7 @@ func createInstanceLock(lockFilePath string) error { // create lock file // TODO: Investigate required permissions. - err = os.WriteFile(lockFilePath, []byte(fmt.Sprintf("%d", os.Getpid())), 0o0666) //nolint:gosec + err = os.WriteFile(lockFilePath, []byte(strconv.Itoa(os.Getpid())), 0o0666) //nolint:gosec if err != nil { return err } diff --git a/cmds/portmaster-start/main.go b/cmds/portmaster-start/main.go index 246b2075..fd2e5a32 100644 --- a/cmds/portmaster-start/main.go +++ b/cmds/portmaster-start/main.go @@ -20,7 +20,7 @@ import ( portlog "github.com/safing/portbase/log" "github.com/safing/portbase/updater" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) var ( @@ -83,7 +83,7 @@ func main() { cobra.OnInitialize(initCobra) // set meta info - info.Set("Portmaster Start", "1.6.0", "AGPLv3", false) + info.Set("Portmaster Start", "", "GPLv3") // catch interrupt for clean shutdown signalCh := make(chan os.Signal, 2) diff --git a/cmds/portmaster-start/recover_linux.go b/cmds/portmaster-start/recover_linux.go index ecb9a219..96719bd8 100644 --- a/cmds/portmaster-start/recover_linux.go +++ b/cmds/portmaster-start/recover_linux.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" - "github.com/safing/portmaster/firewall/interception" + "github.com/safing/portmaster/service/firewall/interception" ) var recoverIPTablesCmd = &cobra.Command{ diff --git a/cmds/portmaster-start/run.go b/cmds/portmaster-start/run.go index 0536f30a..12606d8e 100644 --- a/cmds/portmaster-start/run.go +++ b/cmds/portmaster-start/run.go @@ -16,7 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/tevino/abool" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) const ( diff --git a/cmds/portmaster-start/show.go b/cmds/portmaster-start/show.go index 207b7183..7ae6fc85 100644 --- a/cmds/portmaster-start/show.go +++ b/cmds/portmaster-start/show.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) func init() { diff --git a/cmds/portmaster-start/update.go b/cmds/portmaster-start/update.go index 805e0cae..bbcec860 100644 --- a/cmds/portmaster-start/update.go +++ b/cmds/portmaster-start/update.go @@ -10,7 +10,7 @@ import ( portlog "github.com/safing/portbase/log" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) var ( diff --git a/cmds/portmaster-start/verify.go b/cmds/portmaster-start/verify.go index ded921b8..7fb7be08 100644 --- a/cmds/portmaster-start/verify.go +++ b/cmds/portmaster-start/verify.go @@ -15,7 +15,7 @@ import ( "github.com/safing/jess/filesig" portlog "github.com/safing/portbase/log" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) var ( diff --git a/cmds/testsuite/.gitignore b/cmds/testsuite/.gitignore new file mode 100644 index 00000000..08e00271 --- /dev/null +++ b/cmds/testsuite/.gitignore @@ -0,0 +1,3 @@ +# Compiled binaries +testsuite +testsuite.exe diff --git a/cmds/testsuite/db.go b/cmds/testsuite/db.go new file mode 100644 index 00000000..848e4d89 --- /dev/null +++ b/cmds/testsuite/db.go @@ -0,0 +1,33 @@ +package main + +import ( + "github.com/safing/portbase/database" + _ "github.com/safing/portbase/database/storage/hashmap" +) + +func setupDatabases(path string) error { + err := database.InitializeWithPath(path) + if err != nil { + return err + } + + _, err = database.Register(&database.Database{ + Name: "core", + Description: "Holds core data, such as settings and profiles", + StorageType: "hashmap", + }) + if err != nil { + return err + } + + _, err = database.Register(&database.Database{ + Name: "cache", + Description: "Cached data, such as Intelligence and DNS Records", + StorageType: "hashmap", + }) + if err != nil { + return err + } + + return nil +} diff --git a/cmds/testsuite/login.go b/cmds/testsuite/login.go new file mode 100644 index 00000000..bf1e5ef3 --- /dev/null +++ b/cmds/testsuite/login.go @@ -0,0 +1,125 @@ +package main + +import ( + "errors" + "fmt" + "log" + + "github.com/spf13/cobra" + + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/access/account" +) + +var ( + loginCmd = &cobra.Command{ + Use: "login", + Short: "Test login and token issuing", + RunE: runTestCommand(testLogin), + } + + loginUsername string + loginPassword string + loginDeviceID string +) + +func init() { + rootCmd.AddCommand(loginCmd) + + // Add flags for login options. + flags := loginCmd.Flags() + flags.StringVar(&loginUsername, "username", "", "set username to use for the login test") + flags.StringVar(&loginPassword, "password", "", "set password to use for the login test") + flags.StringVar(&loginDeviceID, "device-id", "", "set device ID to use for the login test") + + // Mark all as required. + _ = loginCmd.MarkFlagRequired("username") + _ = loginCmd.MarkFlagRequired("password") + _ = loginCmd.MarkFlagRequired("device-id") +} + +func testLogin(cmd *cobra.Command, args []string) (err error) { + // Init token zones. + err = access.InitializeZones() + if err != nil { + return fmt.Errorf("failed to initialize token zones: %w", err) + } + + // Set initial user object in order to set the device ID that should be used for login. + initialUser := &access.UserRecord{ + User: &account.User{ + Username: loginUsername, + Device: &account.Device{ + ID: loginDeviceID, + }, + }, + } + err = initialUser.Save() + if err != nil { + return fmt.Errorf("failed to save initial user with device ID: %w", err) + } + + // Login. + _, _, err = access.Login(loginUsername, loginPassword) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + // Check user. + user, err := access.GetUser() + if err != nil { + return fmt.Errorf("failed to get user after login: %w", err) + } + if verbose { + log.Printf("user (from login): %+v", user.User) + log.Printf("device (from login): %+v", user.User.Device) + } + + // Check if the device ID is unchanged. + if user.Device.ID != loginDeviceID { + return errors.New("device ID changed") + } + + // Check Auth Token. + authToken, err := access.GetAuthToken() + if err != nil { + return fmt.Errorf("failed to get auth token after login: %w", err) + } + if verbose { + log.Printf("auth token (from login): %+v", authToken.Token) + } + firstAuthToken := authToken.Token.Token + + // Update User. + _, _, err = access.UpdateUser() + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + // Check if we received a new Auth Token. + authToken, err = access.GetAuthToken() + if err != nil { + return fmt.Errorf("failed to get auth token after user update: %w", err) + } + if verbose { + log.Printf("auth token (from update): %+v", authToken.Token) + } + if authToken.Token.Token == firstAuthToken { + return errors.New("auth token did not change after update") + } + + // Get Tokens. + err = access.UpdateTokens() + if err != nil { + return fmt.Errorf("failed to update tokens: %w", err) + } + regular, fallback := access.GetTokenAmount(access.ExpandAndConnectZones) + if verbose { + log.Printf("received tokens: %d regular, %d fallback", regular, fallback) + } + if regular == 0 || fallback == 0 { + return fmt.Errorf("not enough tokens after fetching: %d regular, %d fallback", regular, fallback) + } + + return nil +} diff --git a/cmds/testsuite/main.go b/cmds/testsuite/main.go new file mode 100644 index 00000000..d4edcead --- /dev/null +++ b/cmds/testsuite/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/spf13/cobra" +) + +var ( + rootCmd = &cobra.Command{ + Use: "testsuite", + Short: "An integration and end-to-end test tool for the SPN", + } + + verbose bool +) + +func runTestCommand(cmdFunc func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + // Setup + dbDir, err := os.MkdirTemp("", "spn-testsuite-") + if err != nil { + makeReports(cmd, fmt.Errorf("internal test error: failed to setup datbases: %w", err)) + return err + } + if err = setupDatabases(dbDir); err != nil { + makeReports(cmd, fmt.Errorf("internal test error: failed to setup datbases: %w", err)) + return err + } + + // Run Test + err = cmdFunc(cmd, args) + if err != nil { + log.Printf("test failed: %s", err) + } + + // Report + makeReports(cmd, err) + + // Cleanup and return more important error. + cleanUpErr := os.RemoveAll(dbDir) + if cleanUpErr != nil { + // Only log if the test failed, so we can return the more important error + if err == nil { + return cleanUpErr + } + log.Printf("cleanup failed: %s", err) + } + + return err + } +} + +func makeReports(cmd *cobra.Command, err error) { + reportToHealthCheckIfEnabled(cmd, err) +} + +func init() { + flags := rootCmd.PersistentFlags() + flags.BoolVarP(&verbose, "verbose", "v", false, "enable verbose logging") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmds/testsuite/report_healthcheck.go b/cmds/testsuite/report_healthcheck.go new file mode 100644 index 00000000..4ca9eb4a --- /dev/null +++ b/cmds/testsuite/report_healthcheck.go @@ -0,0 +1,51 @@ +package main + +import ( + "log" + "net/http" + "strings" + + "github.com/spf13/cobra" +) + +var healthCheckReportURL string + +func init() { + flags := rootCmd.PersistentFlags() + flags.StringVar(&healthCheckReportURL, "report-to-healthcheck", "", "report to the given healthchecks URL") +} + +func reportToHealthCheckIfEnabled(_ *cobra.Command, failureErr error) { + if healthCheckReportURL == "" { + return + } + + if failureErr != nil { + // Report failure. + resp, err := http.Post( + healthCheckReportURL+"/fail", + "text/plain; utf-8", + strings.NewReader(failureErr.Error()), + ) + if err != nil { + log.Printf("failed to report failure to healthcheck at %q: %s", healthCheckReportURL, err) + return + } + _ = resp.Body.Close() + + // Always log that we've report the error. + log.Printf("reported failure to healthcheck at %q", healthCheckReportURL) + } else { + // Report success. + resp, err := http.Get(healthCheckReportURL) //nolint:gosec + if err != nil { + log.Printf("failed to report success to healthcheck at %q: %s", healthCheckReportURL, err) + return + } + _ = resp.Body.Close() + + if verbose { + log.Printf("reported success to healthcheck at %q", healthCheckReportURL) + } + } +} diff --git a/cmds/winkext-test/main.go b/cmds/winkext-test/main.go index 8c7bf2cf..9b17b1a3 100644 --- a/cmds/winkext-test/main.go +++ b/cmds/winkext-test/main.go @@ -1,8 +1,10 @@ +//go:build windows // +build windows package main import ( + "context" "flag" "fmt" "os" @@ -11,8 +13,8 @@ import ( "syscall" "github.com/safing/portbase/log" - "github.com/safing/portmaster/firewall/interception/windowskext" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/firewall/interception/windowskext" + "github.com/safing/portmaster/service/network/packet" ) var ( @@ -74,7 +76,7 @@ func main() { log.Infof("using .sys at %s", sysPath) // init - err = windowskext.Init(dllPath, sysPath) + err = windowskext.Init(sysPath) if err != nil { log.Criticalf("failed to init kext: %s", err) return @@ -88,7 +90,7 @@ func main() { } packets = make(chan packet.Packet, 1000) - go windowskext.Handler(packets) + go windowskext.Handler(context.TODO(), packets) go handlePackets() // catch interrupt for clean shutdown @@ -134,12 +136,8 @@ func handlePackets() { handledPackets++ if getPayload { - data, err := pkt.GetPayload() - if err != nil { - log.Errorf("failed to get payload: %s", err) - } else { - log.Infof("payload is: %x", data) - } + data := pkt.Payload() + log.Infof("payload is: %x", data) } // reroute dns requests to nameserver diff --git a/cmds/winkext-test/main_linux.go b/cmds/winkext-test/main_linux.go new file mode 100644 index 00000000..951e30d2 --- /dev/null +++ b/cmds/winkext-test/main_linux.go @@ -0,0 +1,10 @@ +//go:build linux +// +build linux + +package main + +import "log" + +func main() { + log.Fatalf("winkext-test not supported on linux") +} diff --git a/desktop/angular/.eslintrc.json b/desktop/angular/.eslintrc.json new file mode 100644 index 00000000..8bcdf9d2 --- /dev/null +++ b/desktop/angular/.eslintrc.json @@ -0,0 +1,54 @@ +{ + "root": true, + "ignorePatterns": [ + "projects/**/*" + ], + "parserOptions": { + "tsconfigRootDir": "desktop/angular" + }, + "overrides": [ + { + "files": [ + "*.ts" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ], + "@typescript-eslint/no-explicit-any": "off" + } + }, + { + "files": [ + "*.html" + ], + "extends": [ + "plugin:@angular-eslint/template/recommended", + "plugin:@angular-eslint/template/accessibility" + ], + "rules": { + "@angular-eslint/template/click-events-have-key-events": "off", + "@angular-eslint/template/interactive-supports-focus": "off" + } + } + ] +} diff --git a/desktop/angular/.gitignore b/desktop/angular/.gitignore new file mode 100644 index 00000000..d86cd691 --- /dev/null +++ b/desktop/angular/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +dist-extension +dist-lib +.angular \ No newline at end of file diff --git a/desktop/angular/README.md b/desktop/angular/README.md new file mode 100644 index 00000000..657f3808 --- /dev/null +++ b/desktop/angular/README.md @@ -0,0 +1,104 @@ +# Portmaster + +Welcome to the new Portmaster User-Interface. It's based on Angular and is built, unit and e2e tested using `@angular/cli`. + +## Running locally + +This section explains how to prepare your Ubuntu machine to build and test the new Portmaster User-Interface. It's recommended to use +a virtual machine but running it on bare metal will work as well. You can use the new Portmaster UI as well as the old one in parallel so +you can simply switch back when something is still missing or buggy. + +1. **Prepare your tooling** + +There's a simple dockerized way to build and test the new UI. Just make sure to have docker installed: + +```bash +sudo apt update +sudo apt install -y docker.io git +sudo systemctl enable --now docker +sudo gpasswd -a $USER docker +``` + +2. **Portmaster installation** + +Next, make sure to install the Portmaster using the official .deb installer from [here](https://updates.safing.io/latest/linux_amd64/packages/portmaster-installer.deb). See the [Wiki](https://github.com/safing/portmaster/wiki/Linux) for more information. + +Once the Portmaster is installed we need to add two new configuration flags. Execute the following: + +```bash +echo 'PORTMASTER_ARGS="--experimental-nfqueue --devmode"' | sudo tee /etc/default/portmaster +sudo systemctl daemon-reload +sudo systemctl restart portmaster +``` + +3. **Build and run the new UI** + +Now, clone this repository and execute the `docker.sh` script: + +```bash +# Clone the repository +git clone https://github.com/safing/portmaster-ui + +# Enter the repo and checkout the correct branch +cd portmaster-ui +git checkout feature/new-ui + +# Enter the directory and run docker.sh +cd modules/portmaster +sudo bash ./docker.sh +``` + +Finally open your browser and point it to http://localhost:8080. + +## Hacking Quick Start + +Although everything should work in the docker container as well, for the best development experience it's recommended to install `@angular/cli` locally. + +It's highly recommended to: +- Use [VSCode](https://code.visualstudio.com/) (or it's oss or server-side variant) with + - the official [Angular Language Service](https://marketplace.visualstudio.com/items?itemName=Angular.ng-template) extension + - the [Tailwind CSS Extension Pack](https://marketplace.visualstudio.com/items?itemName=andrewmcodes.tailwindcss-extension-pack) extension + - the [formate: CSS/LESS/SCSS formatter](https://github.com/mblander/formate) extension + +### Folder Structure + +From the project root (the folder containing this [README.md](./)) there are only two folders with the following content and structure: + +- **`src/`** contains the actual application sources: + - **`app/`** contains the actual application sources (components, services, uni tests ...) + - **`layout/`** contains components that form the overall application layout. For example the navigation bar and the side dash are located there. + - **`pages/`** contains the different pages of the application. A page is something that is associated with a dedicated application route and is rendered at the applications main content. + - **`services/`** contains shared services (like PortAPI and friends) + - **`shared/`** contains shared components that are likely used accross other components or pages. + - **`widgets/`** contains widgets and their settings components for the application side dash. + - **`debug/`** contains a debug sidebar component + - **`assets/`** contains static assets that must be shipped seperately. + - **`environments/`** contains build and production related environment settings (those are handled by `@angular/cli` automatically, see [angular.json](angular.json)) +- **`e2e/`** contains end-to-end testing sources. + + +### Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. + +In development mode (that is, you don't pass `--prod`) the UI expects portmaster running at `ws://127.0.0.1:817/api/database/v1`. See [environment](./src/app/environments/environment.ts). + +### Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +### Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. + +### Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +### Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). + +### Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). diff --git a/desktop/angular/angular.json b/desktop/angular/angular.json new file mode 100644 index 00000000..d99f44d0 --- /dev/null +++ b/desktop/angular/angular.json @@ -0,0 +1,457 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "portmaster": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + }, + "@schematics/angular:application": { + "strict": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "tsconfig.app.json", + "aot": true, + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/theme.less", + "src/styles.scss", + "node_modules/prismjs/themes/prism-okaidia.css", + "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css" + ], + "stylePreprocessorOptions": { + "includePaths": [ + "dist-lib/" + ] + }, + "scripts": [ + "node_modules/marked/marked.min.js", + "node_modules/emoji-toolkit/lib/js/joypixels.min.js", + "node_modules/prismjs/prism.js", + "node_modules/prismjs/components/prism-yaml.min.js", + "node_modules/prismjs/components/prism-json.min.js", + "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js" + ], + "vendorChunk": true, + "extractLicenses": false, + "buildOptimizer": false, + "sourceMap": true, + "optimization": false, + "namedChunks": true + }, + "configurations": { + "development": {}, + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false + } + }, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": true, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "4mb", + "maximumError": "16mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4mb", + "maximumError": "16mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "portmaster:build" + }, + "configurations": { + "production": { + "browserTarget": "portmaster:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "portmaster:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "tsconfig.spec.json", + "karmaConfig": "karma.conf.js", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + } + }, + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "portmaster:serve" + }, + "configurations": { + "production": { + "devServerTarget": "portmaster:serve:production" + } + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } + } + } + }, + "@safing/ui": { + "projectType": "library", + "root": "projects/safing/ui", + "sourceRoot": "projects/safing/ui/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "projects/safing/ui/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/safing/ui/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/safing/ui/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "projects/safing/ui/src/test.ts", + "tsConfig": "projects/safing/ui/tsconfig.spec.json", + "karmaConfig": "projects/safing/ui/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "projects/safing/ui/**/*.ts", + "projects/safing/ui/**/*.html" + ] + } + } + } + }, + "portmaster-chrome-extension": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "projects/portmaster-chrome-extension", + "sourceRoot": "projects/portmaster-chrome-extension/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-builders/custom-webpack:browser", + "options": { + "customWebpackConfig": { + "path": "./browser-extension.config.ts" + }, + "outputPath": "dist-extension", + "index": "projects/portmaster-chrome-extension/src/index.html", + "main": "projects/portmaster-chrome-extension/src/main.ts", + "polyfills": "projects/portmaster-chrome-extension/src/polyfills.ts", + "tsConfig": "projects/portmaster-chrome-extension/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "projects/portmaster-chrome-extension/src/favicon.ico", + "projects/portmaster-chrome-extension/src/assets", + "projects/portmaster-chrome-extension/src/manifest.json" + ], + "styles": [ + "projects/portmaster-chrome-extension/src/styles.scss" + ], + "scripts": [], + "optimization": { + "styles": { + "inlineCritical": false + } + }, + "outputHashing": "none" + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "fileReplacements": [ + { + "replace": "projects/portmaster-chrome-extension/src/environments/environment.ts", + "with": "projects/portmaster-chrome-extension/src/environments/environment.prod.ts" + } + ], + "outputHashing": "none" + }, + "development": { + "customWebpackConfig": { + "path": "./browser-extension-dev.config.ts" + }, + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "browserTarget": "portmaster-chrome-extension:build:production" + }, + "development": { + "browserTarget": "portmaster-chrome-extension:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "portmaster-chrome-extension:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "projects/portmaster-chrome-extension/src/test.ts", + "polyfills": "projects/portmaster-chrome-extension/src/polyfills.ts", + "tsConfig": "projects/portmaster-chrome-extension/tsconfig.spec.json", + "karmaConfig": "projects/portmaster-chrome-extension/karma.conf.js", + "inlineStyleLanguage": "scss", + "assets": [ + "projects/portmaster-chrome-extension/src/favicon.ico", + "projects/portmaster-chrome-extension/src/assets" + ], + "styles": [ + "projects/portmaster-chrome-extension/src/styles.scss" + ], + "scripts": [] + } + } + } + }, + "@safing/portmaster-api": { + "projectType": "library", + "root": "projects/safing/portmaster-api", + "sourceRoot": "projects/safing/portmaster-api/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "projects/safing/portmaster-api/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/safing/portmaster-api/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/safing/portmaster-api/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "projects/safing/portmaster-api/src/test.ts", + "tsConfig": "projects/safing/portmaster-api/tsconfig.spec.json", + "karmaConfig": "projects/safing/portmaster-api/karma.conf.js" + } + } + } + }, + "tauri-builtin": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "skipTests": true, + "style": "scss", + "standalone": true + }, + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true, + "standalone": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true, + "standalone": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "projects/tauri-builtin", + "sourceRoot": "projects/tauri-builtin/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/tauri-builtin", + "index": "projects/tauri-builtin/src/index.html", + "main": "projects/tauri-builtin/src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "projects/tauri-builtin/tsconfig.app.json", + "assets": [ + "projects/tauri-builtin/src/favicon.ico", + "projects/tauri-builtin/src/assets" + ], + "styles": [ + "projects/tauri-builtin/src/styles.scss" + ], + "inlineStyleLanguage": "scss", + "stylePreprocessorOptions": { + "includePaths": [ + "dist-lib/" + ] + }, + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "browserTarget": "tauri-builtin:build:production" + }, + "development": { + "browserTarget": "tauri-builtin:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "tauri-builtin:build" + } + } + } + } + }, + "cli": { + "analytics": false + }, + "schematics": { + "@angular-eslint/schematics:application": { + "setParserOptionsProject": true + }, + "@angular-eslint/schematics:library": { + "setParserOptionsProject": true + } + } +} \ No newline at end of file diff --git a/desktop/angular/assets b/desktop/angular/assets new file mode 120000 index 00000000..21dab851 --- /dev/null +++ b/desktop/angular/assets @@ -0,0 +1 @@ +../../assets/data \ No newline at end of file diff --git a/desktop/angular/browser-extension-dev.config.ts b/desktop/angular/browser-extension-dev.config.ts new file mode 100644 index 00000000..a05dbcb1 --- /dev/null +++ b/desktop/angular/browser-extension-dev.config.ts @@ -0,0 +1,16 @@ +import type { Configuration } from 'webpack'; +const ExtensionReloader = require('webpack-ext-reloader'); +const config = require('./browser-extension.config'); + +module.exports = { + ...config, + mode: 'development', + plugins: [ + new ExtensionReloader({ + reloadPage: true, // Force the reload of the page also + entries: { // The entries used for the content/background scripts or extension pages + background: 'background', + } + }) + ] +} as Configuration; diff --git a/desktop/angular/browser-extension.config.ts b/desktop/angular/browser-extension.config.ts new file mode 100644 index 00000000..df5de5d3 --- /dev/null +++ b/desktop/angular/browser-extension.config.ts @@ -0,0 +1,5 @@ +import type { Configuration } from 'webpack'; + +module.exports = { + entry: { background: { import: 'projects/portmaster-chrome-extension/src/background.ts', runtime: false } }, +} as Configuration; diff --git a/desktop/angular/docker.sh b/desktop/angular/docker.sh new file mode 100755 index 00000000..bbd896e7 --- /dev/null +++ b/desktop/angular/docker.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# cd to script dir +baseDir="$( cd "$(dirname "$0")" && pwd )" +cd "$baseDir" + +# get base dir for mounting +mnt="$( cd ../.. && pwd )" + +# run container and start dev server +docker run \ + -ti \ + --rm \ + -v $mnt:/portmaster-ui \ + -w /portmaster-ui/modules/portmaster \ + -p 8081:8080 \ + node:latest \ + npm start -- --host 0.0.0.0 --port 8080 diff --git a/desktop/angular/e2e/protractor.conf.js b/desktop/angular/e2e/protractor.conf.js new file mode 100644 index 00000000..f238c0bb --- /dev/null +++ b/desktop/angular/e2e/protractor.conf.js @@ -0,0 +1,36 @@ +// @ts-check +// Protractor configuration file, see link for more information +// https://github.com/angular/protractor/blob/master/lib/config.ts + +const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); + +/** + * @type { import("protractor").Config } + */ +exports.config = { + allScriptsTimeout: 11000, + specs: [ + './src/**/*.e2e-spec.ts' + ], + capabilities: { + browserName: 'chrome' + }, + directConnect: true, + baseUrl: 'http://localhost:4200/', + framework: 'jasmine', + jasmineNodeOpts: { + showColors: true, + defaultTimeoutInterval: 30000, + print: function() {} + }, + onPrepare() { + require('ts-node').register({ + project: require('path').join(__dirname, './tsconfig.json') + }); + jasmine.getEnv().addReporter(new SpecReporter({ + spec: { + displayStacktrace: StacktraceOption.PRETTY + } + })); + } +}; \ No newline at end of file diff --git a/desktop/angular/e2e/src/app.e2e-spec.ts b/desktop/angular/e2e/src/app.e2e-spec.ts new file mode 100644 index 00000000..ada7d128 --- /dev/null +++ b/desktop/angular/e2e/src/app.e2e-spec.ts @@ -0,0 +1,23 @@ +import { AppPage } from './app.po'; +import { browser, logging } from 'protractor'; + +describe('workspace-project App', () => { + let page: AppPage; + + beforeEach(() => { + page = new AppPage(); + }); + + it('should display welcome message', () => { + page.navigateTo(); + expect(page.getTitleText()).toEqual('portmaster app is running!'); + }); + + afterEach(async () => { + // Assert that there are no errors emitted from the browser + const logs = await browser.manage().logs().get(logging.Type.BROWSER); + expect(logs).not.toContain(jasmine.objectContaining({ + level: logging.Level.SEVERE, + } as logging.Entry)); + }); +}); diff --git a/desktop/angular/e2e/src/app.po.ts b/desktop/angular/e2e/src/app.po.ts new file mode 100644 index 00000000..b68475e0 --- /dev/null +++ b/desktop/angular/e2e/src/app.po.ts @@ -0,0 +1,11 @@ +import { browser, by, element } from 'protractor'; + +export class AppPage { + navigateTo(): Promise { + return browser.get(browser.baseUrl) as Promise; + } + + getTitleText(): Promise { + return element(by.css('app-root .content span')).getText() as Promise; + } +} diff --git a/desktop/angular/e2e/tsconfig.json b/desktop/angular/e2e/tsconfig.json new file mode 100644 index 00000000..426058ef --- /dev/null +++ b/desktop/angular/e2e/tsconfig.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/e2e", + "module": "commonjs", + "target": "es2018", + "types": [ + "jasmine", + "jasminewd2", + "node" + ] + } +} diff --git a/desktop/angular/karma.conf.js b/desktop/angular/karma.conf.js new file mode 100644 index 00000000..344d4317 --- /dev/null +++ b/desktop/angular/karma.conf.js @@ -0,0 +1,32 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, './coverage/portmaster'), + reports: ['html', 'lcovonly', 'text-summary'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true + }); +}; diff --git a/desktop/angular/package-lock.json b/desktop/angular/package-lock.json new file mode 100644 index 00000000..13f80b4f --- /dev/null +++ b/desktop/angular/package-lock.json @@ -0,0 +1,34959 @@ +{ + "name": "portmaster", + "version": "0.8.3", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "portmaster", + "version": "0.8.3", + "dependencies": { + "@angular/animations": "^16.0.1", + "@angular/cdk": "^16.0.1", + "@angular/common": "^16.0.1", + "@angular/compiler": "^16.0.1", + "@angular/core": "^16.0.1", + "@angular/forms": "^16.0.1", + "@angular/localize": "^16.0.1", + "@angular/platform-browser": "^16.0.1", + "@angular/platform-browser-dynamic": "^16.0.1", + "@angular/router": "^16.0.1", + "@fortawesome/angular-fontawesome": "^0.13.0", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-brands-svg-icons": "^6.4.0", + "@fortawesome/free-regular-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@tauri-apps/api": "^2.0.0-beta.3", + "@tauri-apps/plugin-cli": "^2.0.0-beta.1", + "@tauri-apps/plugin-clipboard-manager": "^2.0.0-alpha.4", + "@tauri-apps/plugin-dialog": "^2.0.0-alpha.4", + "@tauri-apps/plugin-notification": "^2.0.0-alpha.4", + "@tauri-apps/plugin-os": "^2.0.0-alpha.5", + "@tauri-apps/plugin-shell": "^2.0.0-alpha.4", + "autoprefixer": "^10.4.14", + "d3": "^7.8.4", + "data-urls": "^5.0.0", + "emoji-toolkit": "^7.0.1", + "fuse.js": "^6.6.2", + "ng-zorro-antd": "^16.1.0", + "ngx-markdown": "^16.0.0", + "postcss": "^8.4.23", + "prismjs": "^1.29.0", + "psl": "^1.9.0", + "rxjs": "~7.8.1", + "topojson-client": "^3.1.0", + "topojson-simplify": "^3.0.3", + "tslib": "^2.5.0", + "whatwg-encoding": "^3.1.1", + "zone.js": "^0.13.0" + }, + "devDependencies": { + "@angular-builders/custom-webpack": "^16.0.0-beta.1", + "@angular-devkit/build-angular": "^16.0.1", + "@angular-eslint/builder": "16.0.1", + "@angular-eslint/eslint-plugin": "16.0.1", + "@angular-eslint/eslint-plugin-template": "16.0.1", + "@angular-eslint/schematics": "16.0.1", + "@angular-eslint/template-parser": "16.0.1", + "@angular/cli": "^16.0.1", + "@angular/compiler-cli": "^16.0.1", + "@fullhuman/postcss-purgecss": "^5.0.0", + "@types/chrome": "^0.0.236", + "@types/d3": "^7.4.0", + "@types/data-urls": "^3.0.4", + "@types/jasmine": "^4.3.1", + "@types/jasminewd2": "~2.0.10", + "@types/node": "^20.1.5", + "@types/psl": "^1.1.0", + "@types/topojson-client": "^3.1.1", + "@types/topojson-simplify": "^3.0.1", + "@types/whatwg-encoding": "^2.0.3", + "@typescript-eslint/eslint-plugin": "^5.59.6", + "@typescript-eslint/parser": "^5.59.6", + "eslint": "^8.40.0", + "jasmine-core": "^5.0.0", + "jasmine-spec-reporter": "^7.0.0", + "js-yaml-loader": "^1.2.2", + "ng-packagr": "^16.0.1", + "npm-run-all": "^4.1.5", + "postcss-import": "^15.1.0", + "postcss-loader": "^7.3.0", + "postcss-scss": "^4.0.6", + "protractor": "~7.0.0", + "tailwindcss": "^3.3.2", + "ts-node": "^10.9.1", + "tslint": "~6.1.0", + "typescript": "4.9", + "webpack-bundle-analyzer": "^4.8.0", + "webpack-ext-reloader": "^1.1.9", + "zip-a-folder": "^1.1.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", + "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-builders/custom-webpack": { + "version": "16.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-16.0.0-beta.1.tgz", + "integrity": "sha512-C0tpgKJt++ciJ2nXtP2+fHOgzHUNyk5Su7bgTKY3yWMWlC9YfUMOlXHvNnCRUDaLqxXTsxQjGp56o9hPNd5miA==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": ">=0.1600.0 < 0.1700.0", + "@angular-devkit/build-angular": "^16.0.0", + "@angular-devkit/core": "^16.0.0", + "lodash": "^4.17.15", + "ts-node": "^10.0.0", + "tsconfig-paths": "^4.1.0", + "webpack-merge": "^5.7.3" + }, + "engines": { + "node": "^14.20.0 || ^16.13.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^16.0.0" + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/@angular-devkit/build-angular": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-16.0.2.tgz", + "integrity": "sha512-jh6ez6k1tPmLTQ8J2T0CY+aRqLbhCvaExH6pqB7q6/bkDItcLPrybDGfJf05F0dHvZPB2fQEK0xYz9i92POofQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "2.2.1", + "@angular-devkit/architect": "0.1600.2", + "@angular-devkit/build-webpack": "0.1600.2", + "@angular-devkit/core": "16.0.2", + "@babel/core": "7.21.4", + "@babel/generator": "7.21.4", + "@babel/helper-annotate-as-pure": "7.18.6", + "@babel/helper-split-export-declaration": "7.18.6", + "@babel/plugin-proposal-async-generator-functions": "7.20.7", + "@babel/plugin-transform-async-to-generator": "7.20.7", + "@babel/plugin-transform-runtime": "7.21.4", + "@babel/preset-env": "7.21.4", + "@babel/runtime": "7.21.0", + "@babel/template": "7.20.7", + "@discoveryjs/json-ext": "0.5.7", + "@ngtools/webpack": "16.0.2", + "@vitejs/plugin-basic-ssl": "1.0.1", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.14", + "babel-loader": "9.1.2", + "babel-plugin-istanbul": "6.1.1", + "browserslist": "4.21.5", + "cacache": "17.0.6", + "chokidar": "3.5.3", + "copy-webpack-plugin": "11.0.0", + "critters": "0.0.16", + "css-loader": "6.7.3", + "esbuild-wasm": "0.17.18", + "glob": "8.1.0", + "https-proxy-agent": "5.0.1", + "inquirer": "8.2.4", + "jsonc-parser": "3.2.0", + "karma-source-map-support": "1.4.0", + "less": "4.1.3", + "less-loader": "11.1.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.2.1", + "magic-string": "0.30.0", + "mini-css-extract-plugin": "2.7.5", + "mrmime": "1.0.1", + "open": "8.4.2", + "ora": "5.4.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "2.3.1", + "piscina": "3.2.0", + "postcss": "8.4.23", + "postcss-loader": "7.2.4", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.1", + "sass": "1.62.1", + "sass-loader": "13.2.2", + "semver": "7.4.0", + "source-map-loader": "4.0.1", + "source-map-support": "0.5.21", + "terser": "5.17.1", + "text-table": "0.2.0", + "tree-kill": "1.2.2", + "tslib": "2.5.0", + "vite": "4.3.1", + "webpack": "5.80.0", + "webpack-dev-middleware": "6.0.2", + "webpack-dev-server": "4.13.2", + "webpack-merge": "5.8.0", + "webpack-subresource-integrity": "5.1.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "esbuild": "0.17.18" + }, + "peerDependencies": { + "@angular/compiler-cli": "^16.0.0", + "@angular/localize": "^16.0.0", + "@angular/platform-server": "^16.0.0", + "@angular/service-worker": "^16.0.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "karma": "^6.3.0", + "ng-packagr": "^16.0.0", + "protractor": "^7.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0", + "typescript": ">=4.9.3 <5.1" + }, + "peerDependenciesMeta": { + "@angular/localize": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "karma": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "protractor": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/@angular-devkit/build-webpack": { + "version": "0.1600.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1600.2.tgz", + "integrity": "sha512-B7EYoRMZOT3RcorxkXaHvMqwuNSttJCicZ99DmwBC41YlZOxpVVP6uM6wvYINGO0TMtu9bCmKkrSD8IC/hHetQ==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": "0.1600.2", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "webpack": "^5.30.0", + "webpack-dev-server": "^4.0.0" + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/@angular-devkit/core": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.2.tgz", + "integrity": "sha512-V4+t0BHO+QML9O2IiG2mJi8DtjeMOm4LAuG6tNDeiHZGAPOflvSPsKBtVl2JlXX/JxdLmyF4B6kRoAXRMKcwTg==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/@ngtools/webpack": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.0.2.tgz", + "integrity": "sha512-8nPAOs2JLdMrAUf3sMkySzh66sPIkukO6HT8KVj726Dqm0Jtabjnxh0EI15Gkykj7HqH0Zw7/VyxpNQRfTA2UQ==", + "dev": true, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^16.0.0", + "typescript": ">=4.9.3 <5.1", + "webpack": "^5.54.0" + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@angular-builders/custom-webpack/node_modules/postcss-loader": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.2.4.tgz", + "integrity": "sha512-F88rpxxNspo5hatIc+orYwZDtHFaVFOSIVAx+fBfJC1GmhWbVmPWtmg2gXKE1OxJbneOSGn8PWdIwsZFcruS+w==", + "dev": true, + "dependencies": { + "cosmiconfig": "^8.1.3", + "cosmiconfig-typescript-loader": "^4.3.0", + "klona": "^2.0.6", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "ts-node": ">=10", + "typescript": ">=4", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.1600.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.2.tgz", + "integrity": "sha512-2AOP3/dwLywcjkRr3ixR/lb0uBn1jzaMWwQR3o7ye3IuEA2sRtyWhUzsy6V7smKBKWPDIbXvX2TcqYZAJ87ccA==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.0.2", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/architect/node_modules/@angular-devkit/core": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.2.tgz", + "integrity": "sha512-V4+t0BHO+QML9O2IiG2mJi8DtjeMOm4LAuG6tNDeiHZGAPOflvSPsKBtVl2JlXX/JxdLmyF4B6kRoAXRMKcwTg==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-16.0.1.tgz", + "integrity": "sha512-VFhUViBfONOf6Ji4Lfkxlk+GN5l8Owm4Z0McqUIegrXsq3aSSStBBFdaDESpzhS6GIGqEBjjHMUQK8IlWT+EIQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "2.2.1", + "@angular-devkit/architect": "0.1600.1", + "@angular-devkit/build-webpack": "0.1600.1", + "@angular-devkit/core": "16.0.1", + "@babel/core": "7.21.4", + "@babel/generator": "7.21.4", + "@babel/helper-annotate-as-pure": "7.18.6", + "@babel/helper-split-export-declaration": "7.18.6", + "@babel/plugin-proposal-async-generator-functions": "7.20.7", + "@babel/plugin-transform-async-to-generator": "7.20.7", + "@babel/plugin-transform-runtime": "7.21.4", + "@babel/preset-env": "7.21.4", + "@babel/runtime": "7.21.0", + "@babel/template": "7.20.7", + "@discoveryjs/json-ext": "0.5.7", + "@ngtools/webpack": "16.0.1", + "@vitejs/plugin-basic-ssl": "1.0.1", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.14", + "babel-loader": "9.1.2", + "babel-plugin-istanbul": "6.1.1", + "browserslist": "4.21.5", + "cacache": "17.0.6", + "chokidar": "3.5.3", + "copy-webpack-plugin": "11.0.0", + "critters": "0.0.16", + "css-loader": "6.7.3", + "esbuild-wasm": "0.17.18", + "glob": "8.1.0", + "https-proxy-agent": "5.0.1", + "inquirer": "8.2.4", + "jsonc-parser": "3.2.0", + "karma-source-map-support": "1.4.0", + "less": "4.1.3", + "less-loader": "11.1.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.2.1", + "magic-string": "0.30.0", + "mini-css-extract-plugin": "2.7.5", + "mrmime": "1.0.1", + "open": "8.4.2", + "ora": "5.4.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "2.3.1", + "piscina": "3.2.0", + "postcss": "8.4.23", + "postcss-loader": "7.2.4", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.1", + "sass": "1.62.1", + "sass-loader": "13.2.2", + "semver": "7.4.0", + "source-map-loader": "4.0.1", + "source-map-support": "0.5.21", + "terser": "5.17.1", + "text-table": "0.2.0", + "tree-kill": "1.2.2", + "tslib": "2.5.0", + "vite": "4.3.1", + "webpack": "5.80.0", + "webpack-dev-middleware": "6.0.2", + "webpack-dev-server": "4.13.2", + "webpack-merge": "5.8.0", + "webpack-subresource-integrity": "5.1.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "esbuild": "0.17.18" + }, + "peerDependencies": { + "@angular/compiler-cli": "^16.0.0", + "@angular/localize": "^16.0.0", + "@angular/platform-server": "^16.0.0", + "@angular/service-worker": "^16.0.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "karma": "^6.3.0", + "ng-packagr": "^16.0.0", + "protractor": "^7.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0", + "typescript": ">=4.9.3 <5.1" + }, + "peerDependenciesMeta": { + "@angular/localize": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "karma": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "protractor": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.1.tgz", + "integrity": "sha512-7N3Dugrp3Fyyn3Q6RsxFNJJ2m1QuqcF3GHJcX7siINL37Hp6xI/q5gKffcd9rf20H1DYZE0VIbR1Sk31G6hMWg==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.0.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss-loader": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.2.4.tgz", + "integrity": "sha512-F88rpxxNspo5hatIc+orYwZDtHFaVFOSIVAx+fBfJC1GmhWbVmPWtmg2gXKE1OxJbneOSGn8PWdIwsZFcruS+w==", + "dev": true, + "dependencies": { + "cosmiconfig": "^8.1.3", + "cosmiconfig-typescript-loader": "^4.3.0", + "klona": "^2.0.6", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "ts-node": ">=10", + "typescript": ">=4", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-webpack": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1600.1.tgz", + "integrity": "sha512-yCy5A1UwGzpst3QJ/CRo2Y8HWRqTPOfwAPAVl91Lbch7gBFViRvq6E7N1XfQunPu/eXvKxbuq2mFSDqtyZ1mWw==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": "0.1600.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "webpack": "^5.30.0", + "webpack-dev-server": "^4.0.0" + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/architect": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.1.tgz", + "integrity": "sha512-7N3Dugrp3Fyyn3Q6RsxFNJJ2m1QuqcF3GHJcX7siINL37Hp6xI/q5gKffcd9rf20H1DYZE0VIbR1Sk31G6hMWg==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.0.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.1.tgz", + "integrity": "sha512-2uz98IqkKJlgnHbWQ7VeL4pb+snGAZXIama2KXi+k9GsRntdcw+udX8rL3G9SdUGUF+m6+147Y1oRBMHsO/v4w==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-A9D0LTYmiqiBa90GKcSuWb7hUouGIbm/AHbJbjL85WLLRbQA2PwKl7P5Mpd6nS/ZC0kfG4VQY3VOaDvb3qpI9g==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.0.1", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.0", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-eslint/builder": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-16.0.1.tgz", + "integrity": "sha512-yjFltV+r3YjisVjASMPmWB/ASz39wdh0q5g0l6/4G+8yaxl6hEYs5o0ZOGeGdTFstCql8FGY+QKwKgsq9Ec4QQ==", + "dev": true, + "dependencies": { + "@nx/devkit": "16.0.2", + "nx": "16.0.2" + }, + "peerDependencies": { + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-16.0.1.tgz", + "integrity": "sha512-amvTgKHtZoygivW3LAYZ9qjLWsXM7/7eaRvaHdmAEdjyFnYQZ7UbWMPSQNz1mlW/AzTFvk9lGGQORglNOSDnww==", + "dev": true + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-16.0.1.tgz", + "integrity": "sha512-CM9keS9cH1QAfSVfsvhw/oGCZcP/D8gfekWwVNjN/uEMEAak0czn1KOG7JQkE36NXOGtwCpTspMi1aa9CVKo9g==", + "dev": true, + "dependencies": { + "@angular-eslint/utils": "16.0.1", + "@typescript-eslint/utils": "5.59.2" + }, + "peerDependencies": { + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-16.0.1.tgz", + "integrity": "sha512-1hyfs+Iq7K2x3mDDE4985d8vDcMyknbE9HKHKUtRLfLKC9gnV3N5d4+UeySQ7Rrjvgzkc1g9qHADyuhwRWpDSA==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "16.0.1", + "@angular-eslint/utils": "16.0.1", + "@typescript-eslint/type-utils": "5.59.2", + "@typescript-eslint/utils": "5.59.2", + "aria-query": "5.1.3", + "axobject-query": "3.1.1" + }, + "peerDependencies": { + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/scope-manager": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/type-utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.2.tgz", + "integrity": "sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "5.59.2", + "@typescript-eslint/utils": "5.59.2", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/types": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/axobject-query": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", + "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-1oJJEWVbgPkNK1E8rAJfrgxzNWWzJKv3frTHeAm8gvZ7GftYhHjDcrcnxLWrYNxb9+q8Awi0hvGta/4HROmmnA==", + "dev": true, + "dependencies": { + "@angular-eslint/eslint-plugin": "16.0.1", + "@angular-eslint/eslint-plugin-template": "16.0.1", + "@nx/devkit": "16.0.2", + "ignore": "5.2.4", + "nx": "16.0.2", + "strip-json-comments": "3.1.1", + "tmp": "0.2.1" + }, + "peerDependencies": { + "@angular/cli": ">= 16.0.0 < 17.0.0" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-16.0.1.tgz", + "integrity": "sha512-x0+SwSeqa3TiVZan6fE5grHsCkjGqU+zAS2DB6wAw5pyvgNAIjrI4cZEQ8pkgHfXe5tuumTKztlkpisah5s/hg==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "16.0.1", + "eslint-scope": "^7.0.0" + }, + "peerDependencies": { + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@angular-eslint/template-parser/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-16.0.1.tgz", + "integrity": "sha512-2xnJuhIrMZEYK6UyBym6FaFXZgopIIbqfQ4sAtMWY6zYkCEsVUvx5qKIrsnXAwvpDQrv0WiMXteqi/5ICpVMZQ==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "16.0.1", + "@typescript-eslint/utils": "5.59.2" + }, + "peerDependencies": { + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.2", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@angular-eslint/utils/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-eslint/utils/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@angular-eslint/utils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/@angular-eslint/utils/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/@angular/animations": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-16.0.1.tgz", + "integrity": "sha512-ziRq1hGJJuQqQUHqNpEMp9uy1pVutvL8oNvawblh32u4bnLsVQU5gMd6sTonn0x4sphEwMNnuEmp/q6QRIx+pA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/core": "16.0.1" + } + }, + "node_modules/@angular/cdk": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.0.1.tgz", + "integrity": "sha512-GupYss6x84RWEoy3JTYu4Igr2SxHuV6whVKMScQG2/Gm+winOsOn7YWm0IZQuFnjSWIF2Va5B0Tp0IjFHWxTvA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@angular/common": "^16.0.0 || ^17.0.0", + "@angular/core": "^16.0.0 || ^17.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/cli": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.0.1.tgz", + "integrity": "sha512-0vIAcq/S+3NXXN4/gBQFVGaxLUQ0zhRxxHQQuiT7GGII73UySuhwvaFB1BEhYG5HVJjRrP1F0ZYbvsvrmFzfXQ==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": "0.1600.1", + "@angular-devkit/core": "16.0.1", + "@angular-devkit/schematics": "16.0.1", + "@schematics/angular": "16.0.1", + "@yarnpkg/lockfile": "1.1.0", + "ansi-colors": "4.1.3", + "ini": "4.0.0", + "inquirer": "8.2.4", + "jsonc-parser": "3.2.0", + "npm-package-arg": "10.1.0", + "npm-pick-manifest": "8.0.1", + "open": "8.4.2", + "ora": "5.4.1", + "pacote": "15.1.3", + "resolve": "1.22.2", + "semver": "7.4.0", + "symbol-observable": "4.0.0", + "yargs": "17.7.2" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.1.tgz", + "integrity": "sha512-7N3Dugrp3Fyyn3Q6RsxFNJJ2m1QuqcF3GHJcX7siINL37Hp6xI/q5gKffcd9rf20H1DYZE0VIbR1Sk31G6hMWg==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.0.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-16.0.1.tgz", + "integrity": "sha512-ic9Ri4Mepf4c0BTff7o4Oyl/a1vACNXXUzuoTwIjWnIqrH89dtwg7ncTD9Rv0N1lon7r4gXokTbn9A/Yk/0jbw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/core": "16.0.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-16.0.1.tgz", + "integrity": "sha512-7zNo6H1qVQow3T4EUul76SaIDSMRSl0hmtyWUzPjtWkxMjrCPSduqjA4/NHaG0KX1BsUvUtQEoDJ5jv/7EHWTQ==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/core": "16.0.1" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + } + } + }, + "node_modules/@angular/compiler-cli": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-16.0.1.tgz", + "integrity": "sha512-EW7Oxp8EuTz3vCNd4RAncZGB7dCUYviUkBA4PzuyPmL2copZPt12j9qx0pXXF3T6ydjoZ+99ZEgfkKOV6FeU3g==", + "dependencies": { + "@babel/core": "7.19.3", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^3.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.1.2", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^17.2.1" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js", + "ngcc": "bundles/ngcc/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/compiler": "16.0.1", + "typescript": ">=4.9.3 <5.1" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helpers": "^7.19.0", + "@babel/parser": "^7.19.3", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.3", + "@babel/types": "^7.19.3", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/generator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", + "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "dependencies": { + "@babel/types": "^7.21.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@angular/core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-16.0.1.tgz", + "integrity": "sha512-3s4XBbzWgyWcjI0WFlNDKRxsbm4J+OKIL4mJCM9r8gWwno9y0K/giziAm9YMIJ4VOBIvrcMbOh85o44FCk8cRA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.13.0" + } + }, + "node_modules/@angular/forms": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-16.0.1.tgz", + "integrity": "sha512-VbH/YnEBau0q97zI7BjSk0pu/i2S0Y/zmhvA2wgI2CCvtbqT6hCNdE/3rW6ZFBcnuCe+dFhuchXe6dX28epsvg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/common": "16.0.1", + "@angular/core": "16.0.1", + "@angular/platform-browser": "16.0.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/localize": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-16.0.1.tgz", + "integrity": "sha512-2zC7KE/JUA/JCHP+kEDSF8iZ9cyvd6OAPFE74yH8FjixQsaq9WhXiPtGkHC0bg9hWH858bRcCmA9BZr+zjntvA==", + "dependencies": { + "@babel/core": "7.19.3", + "glob": "8.1.0", + "yargs": "^17.2.1" + }, + "bin": { + "localize-extract": "tools/bundles/src/extract/cli.js", + "localize-migrate": "tools/bundles/src/migrate/cli.js", + "localize-translate": "tools/bundles/src/translate/cli.js" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/compiler": "16.0.1", + "@angular/compiler-cli": "16.0.1" + } + }, + "node_modules/@angular/localize/node_modules/@babel/core": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helpers": "^7.19.0", + "@babel/parser": "^7.19.3", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.3", + "@babel/types": "^7.19.3", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/localize/node_modules/@babel/generator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", + "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "dependencies": { + "@babel/types": "^7.21.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@angular/localize/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@angular/localize/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@angular/localize/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@angular/localize/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/platform-browser": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-16.0.1.tgz", + "integrity": "sha512-7XLIOnTnGDJLE4Q0zBz6eI9q5V3NnsTAJqIICJHc4gk6jNgVz90gtejAQ4EFbo0d83XGzwFL22hxID5Dj1WRIA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/animations": "16.0.1", + "@angular/common": "16.0.1", + "@angular/core": "16.0.1" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/platform-browser-dynamic": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-16.0.1.tgz", + "integrity": "sha512-qrGlRPqJM42WZcHCbzwTA8SiK90xrhM/VrOL/8/1okuHn82gSWbbynpqycdZnsI9XMbW+HNhpKR2n8HKV38Jug==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/common": "16.0.1", + "@angular/compiler": "16.0.1", + "@angular/core": "16.0.1", + "@angular/platform-browser": "16.0.1" + } + }, + "node_modules/@angular/router": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-16.0.1.tgz", + "integrity": "sha512-4GH0SxPbuY08B/M0f3NEHf9yIFH+D3wlzWJHI75chfdqQ8gGAMG6B6PSmo3haicDxHcSnZTYNJXDLOQvaBAHcA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/common": "16.0.1", + "@angular/core": "16.0.1", + "@angular/platform-browser": "16.0.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.0.tgz", + "integrity": "sha512-iVm/9PfGCbC0dSMBrz7oiEXZaaGH7ceU40OJEfKmyuzR9R5CRimJYPlRiFtMQGQcbNMea/ePcoIebi4ASGYXtg==", + "dependencies": { + "@ctrl/tinycolor": "^3.4.0" + } + }, + "node_modules/@ant-design/icons-angular": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-angular/-/icons-angular-16.0.0.tgz", + "integrity": "sha512-KWBmWZl2so49R/MdAT7aG+xaBlMKl9SArR3Du/iPA0Am9GI1i9R89KgnnLWz+gkzHTye15S1IBXpgts4GPPU/w==", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": "^16.0.0", + "@angular/core": "^16.0.0", + "@angular/platform-browser": "^16.0.0", + "rxjs": "^6.4.0 || ^7.4.0" + } + }, + "node_modules/@assemblyscript/loader": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.10.1.tgz", + "integrity": "sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", + "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "dependencies": { + "@babel/highlight": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.21.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.7.tgz", + "integrity": "sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", + "integrity": "sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA==", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.21.4", + "@babel/generator": "^7.21.4", + "@babel/helper-compilation-targets": "^7.21.4", + "@babel/helper-module-transforms": "^7.21.2", + "@babel/helpers": "^7.21.0", + "@babel/parser": "^7.21.4", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.4", + "@babel/types": "^7.21.4", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.4.tgz", + "integrity": "sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==", + "dependencies": { + "@babel/types": "^7.21.4", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.21.5.tgz", + "integrity": "sha512-uNrjKztPLkUk7bpCNC0jEKDJzzkvel/W+HguzbN8krA+LPfC1CEobJEvAvGka2A/M+ViOqXdcRL0GqPUJSjx9g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz", + "integrity": "sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==", + "dependencies": { + "@babel/compat-data": "^7.21.5", + "@babel/helper-validator-option": "^7.21.0", + "browserslist": "^4.21.3", + "lru-cache": "^5.1.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.21.8", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.8.tgz", + "integrity": "sha512-+THiN8MqiH2AczyuZrnrKL6cAxFRRQDKW9h1YkBvbgKmAm6mwiacig1qT73DHIWMGo40GRnsEfN3LA+E6NtmSw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-member-expression-to-functions": "^7.21.5", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-replace-supers": "^7.21.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/helper-split-export-declaration": "^7.18.6", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.21.8", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.21.8.tgz", + "integrity": "sha512-zGuSdedkFtsFHGbexAvNuipg1hbtitDLo2XE8/uf6Y9sOQV1xsYX/2pNbtedp/X0eU1pIt+kGvaqHCowkRbS5g==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "regexpu-core": "^5.3.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", + "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0-0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", + "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", + "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "dependencies": { + "@babel/template": "^7.20.7", + "@babel/types": "^7.21.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", + "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.5.tgz", + "integrity": "sha512-nIcGfgwpH2u4n9GG1HpStW5Ogx7x7ekiFHbjjFRKXbn5zUvqO9ZgotCO4x1aNbKn/x/xOUaXEhyNHCwtFCpxWg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz", + "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==", + "dependencies": { + "@babel/types": "^7.21.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz", + "integrity": "sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-module-imports": "^7.21.4", + "@babel/helper-simple-access": "^7.21.5", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/helper-validator-identifier": "^7.19.1", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.5", + "@babel/types": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz", + "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-wrap-function": "^7.18.9", + "@babel/types": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.21.5.tgz", + "integrity": "sha512-/y7vBgsr9Idu4M6MprbOVUfH3vs7tsIfnVWv/Ml2xgwvyH6LTngdfbf5AdsKwkJy4zgy1X/kuNrEKvhhK28Yrg==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-member-expression-to-functions": "^7.21.5", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.5", + "@babel/types": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", + "integrity": "sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==", + "dependencies": { + "@babel/types": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz", + "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", + "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", + "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", + "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz", + "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz", + "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==", + "dev": true, + "dependencies": { + "@babel/helper-function-name": "^7.19.0", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.20.5", + "@babel/types": "^7.20.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.5.tgz", + "integrity": "sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==", + "dependencies": { + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.5", + "@babel/types": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", + "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.18.6", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.21.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.8.tgz", + "integrity": "sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", + "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz", + "integrity": "sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-proposal-optional-chaining": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-proposal-async-generator-functions": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", + "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-class-static-block": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.21.0.tgz", + "integrity": "sha512-XP5G9MWNUskFuP30IfFSEFB0Z6HzLIUcjYM4bYOPHXl7eiJ9HFv8tWj6TXTN5QODiEhDZAeI4hLok2iHFFV4hw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-proposal-dynamic-import": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", + "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-namespace-from": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-json-strings": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", + "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz", + "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-catch-binding": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", + "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", + "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0.tgz", + "integrity": "sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-unicode-property-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz", + "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.21.5.tgz", + "integrity": "sha512-wb1mhwGOCaXHDTcsRYMKF9e5bbMgqwxtqa2Y1ifH96dXJPwbuLX9qHy3clhrxVqgMz7nyNXs8VkxdH8UBcjKqA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz", + "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.21.0.tgz", + "integrity": "sha512-Mdrbunoh9SxwFZapeHVrwFmri16+oYotcZysSzhNIVDwIAb1UV+kvnxULSYq9J3/q5MDG+4X6w8QVgD1zhBXNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.21.0.tgz", + "integrity": "sha512-RZhbYTCEUAe6ntPehC4hlslPWosNHDox+vAs4On/mCLRLfoDVHf6hVEd7kuxr1RnHwJmxFfUM3cZiZRmPxJPXQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-replace-supers": "^7.20.7", + "@babel/helper-split-export-declaration": "^7.18.6", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.21.5.tgz", + "integrity": "sha512-TR653Ki3pAwxBxUe8srfF3e4Pe3FTA46uaNHYyQwIoM4oWKSoOZiDNyHJ0oIoDIUPSRQbQG7jzgVBX3FPVne1Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/template": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.21.3.tgz", + "integrity": "sha512-bp6hwMFzuiE4HqYEyoGJ/V2LeIWn+hLVKc4pnj++E5XQptwhtcGmSayM029d/j2X1bPKGTlsyPwAubuU22KhMA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.5.tgz", + "integrity": "sha512-nYWpjKW/7j/I/mZkGVgHJXh4bA1sfdFnJoOXwJuj4m3Q2EraO/8ZyrkCau9P5tbHQk01RMSt6KYLCsW7730SXQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-function-name": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz", + "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.5.tgz", + "integrity": "sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.21.5", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-simple-access": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz", + "integrity": "sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==", + "dev": true, + "dependencies": { + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-validator-identifier": "^7.19.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz", + "integrity": "sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.20.5", + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", + "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.21.3.tgz", + "integrity": "sha512-Wxc+TvppQG9xWFYatvCGPvZ6+SIUxQ2ZdiBP+PHYMIjnPXD+uThCshaz4NZOnODAtBjjcVQQ/3OKs9LW28purQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz", + "integrity": "sha512-ZoYBKDb6LyMi5yCsByQ5jmXsHAQDDYeexT1Szvlmui+lADvfSecr5Dxd/PkrTC3pAD182Fcju1VQkB4oCp9M+w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "regenerator-transform": "^0.15.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.21.4.tgz", + "integrity": "sha512-1J4dhrw1h1PqnNNpzwxQ2UBymJUF8KuPjAAnlLwZcGhHAIqUigFW7cdK6GHoB64ubY4qXQNYknoUeks4Wz7CUA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.21.4", + "@babel/helper-plugin-utils": "^7.20.2", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz", + "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.21.5.tgz", + "integrity": "sha512-LYm/gTOwZqsYohlvFUe/8Tujz75LqqVC2w+2qPHLR+WyWHGCZPN1KBpJCJn+4Bk4gOkQy/IXKIge6az5MqwlOg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.21.4.tgz", + "integrity": "sha512-2W57zHs2yDLm6GD5ZpvNn71lZ0B/iypSdIeq25OurDKji6AdzV07qp4s3n1/x5BqtiGaTrPN3nerlSCaC5qNTw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.21.4", + "@babel/helper-compilation-targets": "^7.21.4", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-validator-option": "^7.21.0", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.20.7", + "@babel/plugin-proposal-async-generator-functions": "^7.20.7", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-class-static-block": "^7.21.0", + "@babel/plugin-proposal-dynamic-import": "^7.18.6", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@babel/plugin-proposal-json-strings": "^7.18.6", + "@babel/plugin-proposal-logical-assignment-operators": "^7.20.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", + "@babel/plugin-proposal-numeric-separator": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/plugin-proposal-optional-catch-binding": "^7.18.6", + "@babel/plugin-proposal-optional-chaining": "^7.21.0", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-proposal-private-property-in-object": "^7.21.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.18.6", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.20.0", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.20.7", + "@babel/plugin-transform-async-to-generator": "^7.20.7", + "@babel/plugin-transform-block-scoped-functions": "^7.18.6", + "@babel/plugin-transform-block-scoping": "^7.21.0", + "@babel/plugin-transform-classes": "^7.21.0", + "@babel/plugin-transform-computed-properties": "^7.20.7", + "@babel/plugin-transform-destructuring": "^7.21.3", + "@babel/plugin-transform-dotall-regex": "^7.18.6", + "@babel/plugin-transform-duplicate-keys": "^7.18.9", + "@babel/plugin-transform-exponentiation-operator": "^7.18.6", + "@babel/plugin-transform-for-of": "^7.21.0", + "@babel/plugin-transform-function-name": "^7.18.9", + "@babel/plugin-transform-literals": "^7.18.9", + "@babel/plugin-transform-member-expression-literals": "^7.18.6", + "@babel/plugin-transform-modules-amd": "^7.20.11", + "@babel/plugin-transform-modules-commonjs": "^7.21.2", + "@babel/plugin-transform-modules-systemjs": "^7.20.11", + "@babel/plugin-transform-modules-umd": "^7.18.6", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.20.5", + "@babel/plugin-transform-new-target": "^7.18.6", + "@babel/plugin-transform-object-super": "^7.18.6", + "@babel/plugin-transform-parameters": "^7.21.3", + "@babel/plugin-transform-property-literals": "^7.18.6", + "@babel/plugin-transform-regenerator": "^7.20.5", + "@babel/plugin-transform-reserved-words": "^7.18.6", + "@babel/plugin-transform-shorthand-properties": "^7.18.6", + "@babel/plugin-transform-spread": "^7.20.7", + "@babel/plugin-transform-sticky-regex": "^7.18.6", + "@babel/plugin-transform-template-literals": "^7.18.9", + "@babel/plugin-transform-typeof-symbol": "^7.18.9", + "@babel/plugin-transform-unicode-escapes": "^7.18.10", + "@babel/plugin-transform-unicode-regex": "^7.18.6", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.21.4", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "core-js-compat": "^3.25.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "node_modules/@babel/runtime": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", + "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", + "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", + "dependencies": { + "@babel/code-frame": "^7.18.6", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", + "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "@babel/generator": "^7.21.5", + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/parser": "^7.21.5", + "@babel/types": "^7.21.5", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", + "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "dependencies": { + "@babel/types": "^7.21.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz", + "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==", + "dependencies": { + "@babel/helper-string-parser": "^7.21.5", + "@babel/helper-validator-identifier": "^7.19.1", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "optional": true + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", + "integrity": "sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz", + "integrity": "sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.18.tgz", + "integrity": "sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz", + "integrity": "sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz", + "integrity": "sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz", + "integrity": "sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz", + "integrity": "sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz", + "integrity": "sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz", + "integrity": "sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz", + "integrity": "sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz", + "integrity": "sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz", + "integrity": "sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz", + "integrity": "sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz", + "integrity": "sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz", + "integrity": "sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz", + "integrity": "sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz", + "integrity": "sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz", + "integrity": "sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz", + "integrity": "sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz", + "integrity": "sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz", + "integrity": "sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz", + "integrity": "sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.5.2", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fortawesome/angular-fontawesome": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.13.0.tgz", + "integrity": "sha512-gzSPRdveOXNO7NIiMgTyB46aiHG0i98KinnAEqHXi8qzraM/kCcHn/0y3f4MhemX6kftwsFli0IU8RyHmtXlSQ==", + "dependencies": { + "tslib": "^2.4.1" + }, + "peerDependencies": { + "@angular/core": "^16.0.0", + "@fortawesome/fontawesome-svg-core": "~1.2.27 || ~1.3.0-beta2 || ^6.1.0" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz", + "integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz", + "integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.4.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz", + "integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.4.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz", + "integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.4.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz", + "integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.4.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fullhuman/postcss-purgecss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-5.0.0.tgz", + "integrity": "sha512-onDS/b/2pMRzqSoj4qOs2tYFmOpaspjTAgvACIHMPiicu1ptajiBruTrjBzTKdxWdX0ldaBb7wj8nEaTLyFkJw==", + "dev": true, + "dependencies": { + "purgecss": "^5.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz", + "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "dependencies": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true + }, + "node_modules/@ngtools/webpack": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.0.1.tgz", + "integrity": "sha512-CZHFPMiJuOe241kO1VSSPOQ5Z9hWWkY7eSs3hnS50Ntgd4YzlHAydqexmEFpXD2YLOFjdbNETCyJ2BQTM4Kwtw==", + "dev": true, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^16.0.0", + "typescript": ">=4.9.3 <5.1", + "webpack": "^5.54.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", + "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-4.0.4.tgz", + "integrity": "sha512-5yZghx+u5M47LghaybLCkdSyFzV/w4OuH12d96HO389Ik9CDsLaDZJVynSGGVJOLn6gy/k7Dz5XYcplM3uxXRg==", + "dev": true, + "dependencies": { + "@npmcli/promise-spawn": "^6.0.0", + "lru-cache": "^7.4.4", + "npm-pick-manifest": "^8.0.0", + "proc-log": "^3.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", + "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "dev": true, + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "lib/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", + "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-6.0.2.tgz", + "integrity": "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==", + "dev": true, + "dependencies": { + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-6.0.2.tgz", + "integrity": "sha512-NCcr1uQo1k5U+SYlnIrbAh3cxy+OQT1VtqiAbxdymSlptbzBb62AjH2xXgjNCoP073hoa1CfCAcwoZ8k96C4nA==", + "dev": true, + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/promise-spawn": "^6.0.0", + "node-gyp": "^9.0.0", + "read-package-json-fast": "^3.0.0", + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@nrwl/devkit": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-16.0.2.tgz", + "integrity": "sha512-SAEcImeQHdSTauO05FUn2vVl9/y5Kx1LNCZ4YE+SdY5/QRq18fuo/DCWmjOGG9M8r06vYGsAgMzkiB4soimcyA==", + "dev": true, + "dependencies": { + "@nx/devkit": "16.0.2" + } + }, + "node_modules/@nrwl/tao": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-16.0.2.tgz", + "integrity": "sha512-wimEe4OTpI7/nDK67RnpZpEXCU+fzA0sDgpIhMgbpPd0vPmKgaZv4nbs8zrm0goFlacmmnLaGRhhGYMOxE+1Lg==", + "dev": true, + "dependencies": { + "nx": "16.0.2" + }, + "bin": { + "tao": "index.js" + } + }, + "node_modules/@nx/devkit": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-16.0.2.tgz", + "integrity": "sha512-BY1Bj0BbAl6XJL0O+QGTWPs/3WMJTEQ+Y4Lfoq4dZM7RllE6rAylr54NA2wa4lsgordZhq1+0g5PVhKKvSVRRw==", + "dev": true, + "dependencies": { + "@nrwl/devkit": "16.0.2", + "ejs": "^3.1.7", + "ignore": "^5.0.4", + "semver": "7.3.4", + "tmp": "~0.2.1", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "nx": ">= 15 <= 17" + } + }, + "node_modules/@nx/devkit/node_modules/semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nx/devkit/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/@nx/nx-darwin-arm64": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-16.0.2.tgz", + "integrity": "sha512-nAT8WJ/qKGEvUcoFLHHye1dbwCd7b8CTZJlDF+ZkyCD/UZRHt4eJxy8gvKmxgkZTFb2+PPMQt4UORCUGpZzuoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-darwin-x64": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-16.0.2.tgz", + "integrity": "sha512-r0rfOrZaOyrwFR5a0UT05xkYRumfkP65cRSZM1TjCA027AG9llYtkLT1hlz8uMKt+P12zrWVzXSqGLDi022ZZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm-gnueabihf": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-16.0.2.tgz", + "integrity": "sha512-TfDQaGvCIDjn9sPg5U1Fr2rsSul/4PIQB59qrLBJRPiCWgpzwO71Il1qwSX68En+JH3lwXr+g5EjcDIEQ8fGYA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-gnu": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-16.0.2.tgz", + "integrity": "sha512-MICaUp7uz8WVQFXWPrmQaX1o4bdL7f3C7b3MDDf6+Zau6RcyQuw97UEKaYi9OqrV3w8yuPplqoLosFblAgb8uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-musl": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-16.0.2.tgz", + "integrity": "sha512-wcBURG+6A2srm+6ujj8SShjwmYWs0eHI5D8vgZr8Bni+lXbKP/IosE9JGXKtRoh27/owyR8PGHhDVzjv46tlFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-x64-gnu": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-16.0.2.tgz", + "integrity": "sha512-Xyml2gFdVDHUj2g67DKz2aD78x1BciN1ZaaBTCxXL4MHfwR78SZa7mtRtE+1kj5OgVIwupZP50jq7C8GuSn3Hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-x64-musl": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-16.0.2.tgz", + "integrity": "sha512-j3xdN8I5DlTgW5N5eCquyBZswrrYf6EazUCvnEpeejygwh3N6XN7DlD68Bs0CB4Zmd0tWLfTjNVAtUJSP6g2mA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-arm64-msvc": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-16.0.2.tgz", + "integrity": "sha512-R2pzoW3SUFBbe9C1vifJnXuysPl6kmutQHN2yQ9lwJptzPvMxfDU1FuXmKCGRUGmEwFxk/XPhwDL/ZcbABTrzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-x64-msvc": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-16.0.2.tgz", + "integrity": "sha512-r4H/SsqfpIJa8QLSpnscgkMnLsnkRYXj8TcILDrf+nJazfEdJZLUvVhN9O85OB7pskv86NuGfnJmJHHXy6QVQg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.0.4.tgz", + "integrity": "sha512-cTDi+FUDBIUOBKEtj+nhiJ71AZVlkAsQFuGQTun5tV9mwQBQgZvhCzG+URPQc8myeN32yRVZEfVAPCs1RW+Jvg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^3.2.1", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, + "node_modules/@rollup/plugin-json": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.0.0.tgz", + "integrity": "sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.2.tgz", + "integrity": "sha512-Y35fRGUjC3FaurG722uhUuG8YHOJRJQbI6/CkbRkdPotSpDj9NtIN85z1zrcyDcCQIW4qp5mgG72U+gJ0TAFEg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-builtin-module": "^3.2.1", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@schematics/angular": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-16.0.1.tgz", + "integrity": "sha512-MNgH/iB3WWxMLFVHJjtXCHZ8YHtfx2e3mX2Ds5P43OTgSnTk6tHabqvwxJ4wzjoyoPUyXWLhHt0diCmVtDTNeQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.0.1", + "@angular-devkit/schematics": "16.0.1", + "jsonc-parser": "3.2.0" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.1.0.tgz", + "integrity": "sha512-a31EnjuIDSX8IXBUib3cYLDRlPMU36AWX4xS8ysLaNu4ZzUesDiPt83pgrW2X1YLMe5L2HbDyaKK5BrL4cNKaQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@tauri-apps/api": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-beta.3.tgz", + "integrity": "sha512-gDSJzKpBs6efXw2ZWqjl9QVNImY5GR5qygXqB7JK4y7prcQInxnTj2ARFR0vD4wuzkrUHGrlIKraiJJPHWJ9vg==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-cli": { + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-cli/-/plugin-cli-2.0.0-beta.1.tgz", + "integrity": "sha512-8VB0RTFi6SrCZvWDiOW+DVhCo7IsBenWfTIF6f8YAU+TnLSOAxpVc2MOM5PimVdKU2hu+mlpjSmPhd9RSCRfAw==", + "dependencies": { + "@tauri-apps/api": "2.0.0-beta.2" + } + }, + "node_modules/@tauri-apps/plugin-cli/node_modules/@tauri-apps/api": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-beta.2.tgz", + "integrity": "sha512-4r1r6kgttzIWxJ3HxkZQH+b7EiUtKhdUCPbi0KSalD+2T3j6klw+v8VyxhKwEdjM/eo60NE+J33v1E/Urq8puw==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-clipboard-manager": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.0.0-alpha.4.tgz", + "integrity": "sha512-/xPQBXuzD8cSh81xkTphIAKxSD2kGsv8deKK+Qoh+89puay1xJjjnxVv5b9IKKn0G8r8HPm+JDEamlKxQbOgnA==", + "dependencies": { + "@tauri-apps/api": "2.0.0-alpha.12" + } + }, + "node_modules/@tauri-apps/plugin-clipboard-manager/node_modules/@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-alpha.4.tgz", + "integrity": "sha512-4NxBgDzxrZ8hPE9OMRYwsXYN2BxQYI/5l1UKEI5V4srFTZK81Vj5GGksCf7gQREZg7CmBRCk95qYx338A6oCag==", + "dependencies": { + "@tauri-apps/api": "2.0.0-alpha.12" + } + }, + "node_modules/@tauri-apps/plugin-dialog/node_modules/@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0-alpha.4.tgz", + "integrity": "sha512-mXUuZoZEEMAedGNJxPZPLET3vY4lSmHCpfrfZIytJRU6eSxbec90L3fB4YqvW9+yqkplyXkvpiThILbT5A4Q4w==", + "dependencies": { + "@tauri-apps/api": "2.0.0-alpha.12" + } + }, + "node_modules/@tauri-apps/plugin-notification/node_modules/@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0-alpha.5.tgz", + "integrity": "sha512-dedPdad+ykMSZz2KUfrhUDyy32G2WH5aLkYdcACF58KC6GBvKuyR5sQ1ZE/pddo2L6VRhyujLp8zJEfRN3AUcQ==", + "dependencies": { + "@tauri-apps/api": "2.0.0-alpha.12" + } + }, + "node_modules/@tauri-apps/plugin-os/node_modules/@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0-alpha.4.tgz", + "integrity": "sha512-Go/+EwGVuAXbSg2l2M5E2gT6cir66KV4CXC9P4gPHeead8Ar/B9wQvuINzcrYzL/HCcL7fFfKlqqu/XPTN2qvQ==", + "dependencies": { + "@tauri-apps/api": "2.0.0-alpha.12" + } + }, + "node_modules/@tauri-apps/plugin-shell/node_modules/@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true + }, + "node_modules/@tufjs/canonical-json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz", + "integrity": "sha512-QTnf++uxunWvG2z3UFNzAoQPHxnSXOwtaI3iJ+AohhV+5vONuArPjJE7aPXPVXfXJsqrVbZBu9b81AJoSd09IQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-1.0.4.tgz", + "integrity": "sha512-qaGV9ltJP0EO25YfFUPhxRVK0evXFIAGicsVXuRim4Ed9cjPxYhNnNJ49SFmbeLgtxpslIkX317IgpfcHPVj/A==", + "dev": true, + "dependencies": { + "@tufjs/canonical-json": "1.0.0", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tufjs/models/node_modules/minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.236", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.236.tgz", + "integrity": "sha512-ArQoxO9WtDY6GWcT2cpo+D+hyASPeFt7PHQEUDXwQhRS00Rbop07rnEOA046yws0HkM83Tcew/hW6Dgvnj4iMQ==", + "dev": true, + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", + "integrity": "sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==", + "dev": true, + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@types/cors": { + "version": "2.8.13", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz", + "integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz", + "integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==", + "dev": true, + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.2.tgz", + "integrity": "sha512-5mjGjz6XOXKOCdTajXTZ/pMsg236RdiwKPrRPWAEf/2S/+PzwY+LLYShUpeysWaMvsdS7LArh6GdUefoxpchsQ==", + "dev": true + }, + "node_modules/@types/d3-axis": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.1.tgz", + "integrity": "sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==", + "dev": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.1.tgz", + "integrity": "sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==", + "dev": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==", + "dev": true + }, + "node_modules/@types/d3-color": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.0.2.tgz", + "integrity": "sha512-WVx6zBiz4sWlboCy7TCgjeyHpNjMsoF36yaagny1uXfbadc9f+5BeBf7U+lRmQqY3EHbGQpP8UdW8AC+cywSwQ==", + "dev": true + }, + "node_modules/@types/d3-contour": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.1.tgz", + "integrity": "sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==", + "dev": true, + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.0.tgz", + "integrity": "sha512-iGm7ZaGLq11RK3e69VeMM6Oqj2SjKUB9Qhcyd1zIcqn2uE8w9GFB445yCY46NOQO3ByaNyktX1DK+Etz7ZaX+w==", + "dev": true + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==", + "dev": true + }, + "node_modules/@types/d3-drag": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", + "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", + "dev": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz", + "integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==", + "dev": true + }, + "node_modules/@types/d3-ease": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz", + "integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==", + "dev": true + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==", + "dev": true, + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.3.tgz", + "integrity": "sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==", + "dev": true + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "dev": true + }, + "node_modules/@types/d3-geo": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.2.tgz", + "integrity": "sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.0.2.tgz", + "integrity": "sha512-+krnrWOZ+aQB6v+E+jEkmkAx9HvsNAD+1LCD0vlBY3t+HwjKnsBFbpVLx6WWzDzCIuiTWdAxXMEnGnVXpB09qQ==", + "dev": true + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "dev": true, + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", + "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==", + "dev": true + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz", + "integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==", + "dev": true + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz", + "integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==", + "dev": true + }, + "node_modules/@types/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==", + "dev": true + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "dev": true, + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==", + "dev": true + }, + "node_modules/@types/d3-selection": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.2.tgz", + "integrity": "sha512-d29EDd0iUBrRoKhPndhDY6U/PYxOWqgIZwKTooy2UkBfU7TNZNpRho0yLWPxlatQrFWk2mnTu71IZQ4+LRgKlQ==", + "dev": true + }, + "node_modules/@types/d3-shape": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.0.2.tgz", + "integrity": "sha512-5+ButCmIfNX8id5seZ7jKj3igdcxx+S9IDBiT35fQGTLZUfkFgTv+oBH34xgeoWDKpWcMITSzBILWQtBoN5Piw==", + "dev": true, + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "dev": true + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz", + "integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==", + "dev": true + }, + "node_modules/@types/d3-timer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz", + "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==", + "dev": true + }, + "node_modules/@types/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-Sv4qEI9uq3bnZwlOANvYK853zvpdKEm1yz9rcc8ZTsxvRklcs9Fx4YFuGA3gXoQN/c/1T6QkVNjhaRO/cWj94g==", + "dev": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz", + "integrity": "sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==", + "dev": true, + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/data-urls": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/data-urls/-/data-urls-3.0.4.tgz", + "integrity": "sha512-XRY2WVaOFSTKpNMaplqY1unPgAGk/DosOJ+eFrB6LJcFFbRH3nVbwJuGqLmDwdTWWx+V7U614/kmrj1JmCDl2A==", + "dev": true, + "dependencies": { + "@types/whatwg-mimetype": "*", + "@types/whatwg-url": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.2.2.tgz", + "integrity": "sha512-nQxgB8/Sg+QKhnV8e0WzPpxjIGT3tuJDDzybkDi8ItE/IgTlHo07U0shaIjzhcvQxlq9SDRE42lsJ23uvEgJ2A==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true + }, + "node_modules/@types/express": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", + "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.35", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", + "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/filesystem": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.32.tgz", + "integrity": "sha512-Yuf4jR5YYMR2DVgwuCiP11s0xuVRyPKmz8vo6HBY3CGdeMj8af93CFZX+T82+VD1+UqHOxTq31lO7MI7lepBtQ==", + "dev": true, + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.29.tgz", + "integrity": "sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==", + "dev": true + }, + "node_modules/@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", + "dev": true + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/har-format": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.8.tgz", + "integrity": "sha512-OP6L9VuZNdskgNN3zFQQ54ceYD8OLq5IbqO4VK91ORLfOm7WdT/CiT/pHEBSQEqCInJ2y3O6iCm/zGtPElpgJQ==", + "dev": true + }, + "node_modules/@types/http-proxy": { + "version": "1.17.11", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", + "integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/jasmine": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.3.1.tgz", + "integrity": "sha512-Vu8l+UGcshYmV1VWwULgnV/2RDbBaO6i2Ptx7nd//oJPIZGhoI1YLST4VKagD2Pq/Bc2/7zvtvhM7F3p4SN7kQ==", + "dev": true + }, + "node_modules/@types/jasminewd2": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/jasminewd2/-/jasminewd2-2.0.10.tgz", + "integrity": "sha512-J7mDz7ovjwjc+Y9rR9rY53hFWKATcIkrr9DwQWmOas4/pnIPJTXawnzjwpHm3RSxz/e3ZVUvQ7cRbd5UQLo10g==", + "dev": true, + "dependencies": { + "@types/jasmine": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", + "dev": true + }, + "node_modules/@types/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-zK4gSFMjgslsv5Lyvr3O1yCjgmnE4pr8jbG8qVn4QglMwtpvPCf4YT2Wma7Nk95OxUUJI8Z+kzdXohbM7mVpGw==", + "peer": true + }, + "node_modules/@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.1.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.5.tgz", + "integrity": "sha512-IvGD1CD/nego63ySR7vrAKEX3AJTcmrAN2kn+/sDNLi1Ff5kBzDeEdqWDplK+0HAEoLYej137Sk0cUU8OLOlMg==", + "dev": true + }, + "node_modules/@types/psl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/psl/-/psl-1.1.0.tgz", + "integrity": "sha512-HhZnoLAvI2koev3czVPzBNRYvdrzJGLjQbWZhqFmS9Q6a0yumc5qtfSahBGb5g+6qWvA8iiQktqGkwoIXa/BNQ==", + "dev": true + }, + "node_modules/@types/q": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz", + "integrity": "sha1-vShOV8hPEyXacCur/IKlMoGQwMU=", + "dev": true + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "node_modules/@types/selenium-webdriver": { + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-3.0.19.tgz", + "integrity": "sha512-OFUilxQg+rWL2FMxtmIgCkUDlJB6pskkpvmew7yeXfzzsOBb5rc+y2+DjHm+r3r1ZPPcJefK3DveNSYWGiy68g==", + "dev": true + }, + "node_modules/@types/semver": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", + "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", + "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz", + "integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==", + "dev": true, + "dependencies": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/source-list-map": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", + "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", + "dev": true + }, + "node_modules/@types/topojson-client": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.1.tgz", + "integrity": "sha512-E4/Z2Xg56kVLRzYWem/6uOKVcVNqqxEqlWM9qCG2tCV1BxuzvvXC02/ELoGJWgtKkQhfycBPlMFEuTFdA/YiTg==", + "dev": true, + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-simplify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/topojson-simplify/-/topojson-simplify-3.0.1.tgz", + "integrity": "sha512-H7SS2X11Lo3iRT3e7R6jPTAazOoSLD0LKIGq1b+4m/76Md46JfeU3zVIhxfIX9FY7oiyEbXwGumjK1GUXwIIMA==", + "dev": true, + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.2.tgz", + "integrity": "sha512-SGc1NdX9g3UGDp6S+p+uyG+Z8CehS51sUJ9bejA25Xgn2kkAguILk6J9nxXK+0M/mbTBN7ypMA7+4HVLNMJ8ag==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/webextension-polyfill": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.8.3.tgz", + "integrity": "sha512-GN+Hjzy9mXjWoXKmaicTegv3FJ0WFZ3aYz77Wk8TMp1IY3vEzvzj1vnsa0ggV7vMI1i+PUxe4qqnIJKCzf9aTg==", + "dev": true + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true + }, + "node_modules/@types/webpack": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.0.tgz", + "integrity": "sha512-8cP0CzcxUiFuA9xGJkfeVpqmWTk9nx6CWwamRGCj95ph1SmlRRk9KlCZ6avhCbZd4L68LvYT6l1kpdEnQXrF8w==", + "dev": true, + "dependencies": { + "@types/node": "*", + "tapable": "^2.2.0", + "webpack": "^5" + } + }, + "node_modules/@types/webpack-sources": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz", + "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/source-list-map": "*", + "source-map": "^0.7.3" + } + }, + "node_modules/@types/whatwg-encoding": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/whatwg-encoding/-/whatwg-encoding-2.0.3.tgz", + "integrity": "sha512-7TJfeaSFIWAKQ4ZynOb5zV3xzJQEEmL0U0j+uH7tnqfL97apXDTwMo0dB2uAWXAbr2dRRi5/eO9jV9dK/1GkiA==", + "dev": true + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.4.tgz", + "integrity": "sha512-lXCmTWSHJvf0TRSO58nm978b8HJ/EdsSsEKLd3ODHFjo+3VGAyyTp4v50nWvwtzBxSMQrVOK7tcuN0zGPLICMw==", + "dev": true, + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", + "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/type-utils": "5.59.6", + "@typescript-eslint/utils": "5.59.6", + "debug": "^4.3.4", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", + "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz", + "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz", + "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "5.59.6", + "@typescript-eslint/utils": "5.59.6", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/@typescript-eslint/type-utils/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz", + "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz", + "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz", + "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz", + "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.59.6", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.1.tgz", + "integrity": "sha512-pcub+YbFtFhaGRTo1832FQHQSHvMrlb43974e2eS8EKleR3p1cDdkJFPci1UhwkEf1J9Bz+wKBSzqpKp7nNj2A==", + "dev": true, + "engines": { + "node": ">=14.6.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, + "node_modules/@yarnpkg/parsers": { + "version": "3.0.0-rc.43", + "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.0-rc.43.tgz", + "integrity": "sha512-AhFF3mIDfA+jEwQv2WMHmiYhOvmdbh2qhUkDVQfiqzQtUwS4BgoWwom5NpSPg4Ix5vOul+w1690Bt21CkVLpgg==", + "dev": true, + "dependencies": { + "js-yaml": "^3.10.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/@zkochan/js-yaml": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz", + "integrity": "sha512-nzvgl3VfhcELQ8LyVrYOru+UtAy1nrygk2+AGbTm8a5YcO6o8lSjAT+pfg3vJWxIoZKOUhrK6UU7xW/+00kQrg==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@zkochan/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz", + "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.3.0.tgz", + "integrity": "sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "depd": "^2.0.0", + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true + }, + "node_modules/archiver": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.1.tgz", + "integrity": "sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w==", + "dev": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.3", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.0.0", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "dev": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/argparse/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.4.14", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", + "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + } + ], + "dependencies": { + "browserslist": "^4.21.5", + "caniuse-lite": "^1.0.30001464", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", + "dev": true + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/babel-loader": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.2.tgz", + "integrity": "sha512-mN14niXW43tddohGl8HPu5yfQq70iUThvFL/4QzESA7GcZoC0eVOhvWdQ8+3UlSjaDE9MVtsW9mxDY07W7VpVA==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.2", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", + "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.17.7", + "@babel/helper-define-polyfill-provider": "^0.3.3", + "semver": "^6.1.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", + "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.3.3", + "core-js-compat": "^3.25.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", + "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.3.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/blocking-proxy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/blocking-proxy/-/blocking-proxy-1.0.1.tgz", + "integrity": "sha512-KE8NFMZr3mN2E0HcvCgRtX7DjhiIQrwle+nSVJVC/yqFb9+xznHl2ZcoBp2L9qzkI4t4cBFJ1efXF8Dwi132RA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "blocking-proxy": "built/lib/bin.js" + }, + "engines": { + "node": ">=6.9.x" + } + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "node_modules/body-parser/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dev": true, + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.21.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", + "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001449", + "electron-to-chromium": "^1.4.284", + "node-releases": "^2.0.8", + "update-browserslist-db": "^1.0.10" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/browserstack": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/browserstack/-/browserstack-1.6.1.tgz", + "integrity": "sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw==", + "dev": true, + "dependencies": { + "https-proxy-agent": "^2.2.1" + } + }, + "node_modules/browserstack/node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dev": true, + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/browserstack/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/browserstack/node_modules/https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "dev": true, + "dependencies": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/builtins": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", + "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", + "dev": true, + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.0.6.tgz", + "integrity": "sha512-ixcYmEBExFa/+ajIPjcwypxL97CjJyOsH9A/W+4qgEPIpJvKlC+HmVY8nkIck6n3PwUTdgq9c489niJGwl+5Cw==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.4.tgz", + "integrity": "sha512-fDboBse/sl1oXSLhIp0FcCJgzW9KmhC/q8ULTKC82zc+DL3TL7FNb8qlt5qqXN53MsKEUSIcb+7DLmEygOE5Yw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.0", + "minipass": "^5.0.0 || ^6.0.0", + "path-scurry": "^1.7.0" + }, + "bin": { + "glob": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001487", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", + "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/clean-webpack-plugin/node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clean-webpack-plugin/node_modules/del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin/node_modules/del/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin/node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clean-webpack-plugin/node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin/node_modules/is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin/node_modules/is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin/node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/clean-webpack-plugin/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", + "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "optional": true, + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compress-commons": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.1.tgz", + "integrity": "sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ==", + "dev": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "node_modules/copy-anything": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", + "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", + "dev": true, + "dependencies": { + "is-what": "^3.14.1" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.30.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.30.2.tgz", + "integrity": "sha512-nriW1nuJjUgvkEjIot1Spwakz52V9YkYHZAQG6A1eCgC8AA1p0zngrQEP9R0+V6hji5XilWKG1Bd0YRppmGimA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "optional": true, + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cosmiconfig": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", + "dev": true, + "dependencies": { + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz", + "integrity": "sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=7", + "ts-node": ">=10", + "typescript": ">=3" + } + }, + "node_modules/cosmiconfig/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.2.tgz", + "integrity": "sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==", + "dev": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/critters": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.16.tgz", + "integrity": "sha512-JwjgmO6i3y6RWtLYmXwO5jMd+maZt8Tnfu7VVISmEWyQqfLpB8soBswf8/2bu6SBXxtKA68Al3c+qIG1ApT68A==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "css-select": "^4.2.0", + "parse5": "^6.0.1", + "parse5-htmlparser2-tree-adapter": "^6.0.1", + "postcss": "^8.3.7", + "pretty-bytes": "^5.3.0" + } + }, + "node_modules/critters/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/critters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/critters/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/critters/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/critters/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/critters/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "node_modules/critters/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", + "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.19", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", + "dev": true + }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/cytoscape": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.25.0.tgz", + "integrity": "sha512-7MW3Iz57mCUo6JQCho6CmPBCbTlJr7LzyEtIkutG255HLVd4XuBg2I9BkTZLI/e4HoaOB/BiAzXuQybQ95+r9Q==", + "optional": true, + "dependencies": { + "heap": "^0.2.6", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "optional": true, + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "optional": true, + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "optional": true, + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "optional": true + }, + "node_modules/d3": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.8.4.tgz", + "integrity": "sha512-q2WHStdhiBtD8DMmhDPyJmXUxr6VWRngKyiJ5EfXMxPw+tqT6BhNjhJZ4w3BHsNm3QoVfZLY8Orq/qPFczwKRA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz", + "integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz", + "integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.0.1.tgz", + "integrity": "sha512-Wt23xBych5tSy9IYAM1FR2rWIBFWa52B/oF/GYe5zbdHrg08FU8+BuI6X4PvTwPDdqdAdq04fuWJpELtsaEjeA==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz", + "integrity": "sha512-LtAIu54UctRmhGKllleflmHalttH3zkfSi4NlKrTAoFKjC+AFBJohsCAdgCBYQwH0F8hIOGY89X1pPqAchlMkA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.0.1.tgz", + "integrity": "sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.1.0.tgz", + "integrity": "sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.9.tgz", + "integrity": "sha512-rYR4QfVmy+sR44IBDvVtcAmOReGBvRCWDpO2QjYwqgh9yijw6eSHBqaPG/LIOEy7aBsniLvtMW6pg19qJhq60w==", + "optional": true, + "dependencies": { + "d3": "^7.8.2", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/date-format": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.11.tgz", + "integrity": "sha512-VS20KRyorrbMCQmpdl2hg5KaOUsda1RbnsJg461FfrcyCUg+pkd0b40BSW4niQyTheww4DBXQnS7HwSrKkipLw==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", + "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==", + "optional": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-equal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.1.tgz", + "integrity": "sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.0", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.0", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "dependencies": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/del/node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/del/node_modules/globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "dependencies": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/del/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/delaunator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.0.tgz", + "integrity": "sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==", + "dependencies": { + "robust-predicates": "^3.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "optional": true + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "node_modules/dns-packet": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz", + "integrity": "sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ==", + "dev": true, + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.3.tgz", + "integrity": "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ==", + "optional": true + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "node_modules/ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.396.tgz", + "integrity": "sha512-pqKTdqp/c5vsrc0xUPYXTDBo9ixZuGY8es4ZOjjd6HD6bFYbu5QA09VoW3fkY4LF1T0zYk86lN6bZnNlBuOpdQ==" + }, + "node_modules/elkjs": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.8.2.tgz", + "integrity": "sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==", + "optional": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/emoji-toolkit": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-7.0.1.tgz", + "integrity": "sha512-l5aJyAhpC5s4mDuoVuqt4SzVjwIsIvakPh4ZGJJE4KWuWFCEHaXacQFkStVdD9zbRR+/BbRXob7u99o0lQFr8A==" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.2.tgz", + "integrity": "sha512-FKn/3oMiJjrOEOeUub2WCox6JhxBXq/Zn3fZOMCBxKnNYtsdKjxhl7yR3fZhM9PV+rdE75SU5SYMc+2PGzo+Tg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.11.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz", + "integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz", + "integrity": "sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz", + "integrity": "sha512-oW69R+4q2wG+Hc3KZePPZxOiisRIqfKBVo/HLx94QcJeWGU/8sZhCvc829rd1kS366vlJbzBfXf9yWwf0+Ko7w==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.1", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.0", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.5", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/es-module-lexer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", + "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", + "dev": true + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "dev": true, + "dependencies": { + "es6-promise": "^4.0.3" + } + }, + "node_modules/esbuild": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.18.tgz", + "integrity": "sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.18", + "@esbuild/android-arm64": "0.17.18", + "@esbuild/android-x64": "0.17.18", + "@esbuild/darwin-arm64": "0.17.18", + "@esbuild/darwin-x64": "0.17.18", + "@esbuild/freebsd-arm64": "0.17.18", + "@esbuild/freebsd-x64": "0.17.18", + "@esbuild/linux-arm": "0.17.18", + "@esbuild/linux-arm64": "0.17.18", + "@esbuild/linux-ia32": "0.17.18", + "@esbuild/linux-loong64": "0.17.18", + "@esbuild/linux-mips64el": "0.17.18", + "@esbuild/linux-ppc64": "0.17.18", + "@esbuild/linux-riscv64": "0.17.18", + "@esbuild/linux-s390x": "0.17.18", + "@esbuild/linux-x64": "0.17.18", + "@esbuild/netbsd-x64": "0.17.18", + "@esbuild/openbsd-x64": "0.17.18", + "@esbuild/sunos-x64": "0.17.18", + "@esbuild/win32-arm64": "0.17.18", + "@esbuild/win32-ia32": "0.17.18", + "@esbuild/win32-x64": "0.17.18" + } + }, + "node_modules/esbuild-wasm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.17.18.tgz", + "integrity": "sha512-h4m5zVa+KaDuRFIbH9dokMwovvkIjTQJS7/Ry+0Z1paVuS9aIkso2vdA2GmwH9GSvGX6w71WveJ3PfkoLuWaRw==", + "dev": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", + "@humanwhocodes/config-array": "^0.11.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "dev": true, + "dependencies": { + "acorn": "^8.8.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter-asyncresource": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz", + "integrity": "sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ==", + "dev": true + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/express/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/express/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/external-editor/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true, + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.6.tgz", + "integrity": "sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", + "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.2.tgz", + "integrity": "sha512-2GAfyfoaCDRrM6jaOS3UsBts8yJ55VioXdWcOL7dK9zdAuKT71+WBA4ifnNYqVjYv+4SsPxjK0JT4yIIn4cA/g==", + "dev": true, + "dependencies": { + "minipass": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "dev": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "13.1.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.1.4.tgz", + "integrity": "sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.11", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "optional": true, + "dependencies": { + "delegate": "^3.1.2" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", + "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", + "dev": true + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dev": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true + }, + "node_modules/hasown": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hdr-histogram-js": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz", + "integrity": "sha512-Hkn78wwzWHNCp2uarhzQ2SGFLU3JY8SBDDd3TAABK4fc30wm+MuPOrg5QVFVfkKOQd6Bfz3ukJEI+q9sXEkK1g==", + "dev": true, + "dependencies": { + "@assemblyscript/loader": "^0.10.1", + "base64-js": "^1.2.0", + "pako": "^1.0.3" + } + }, + "node_modules/hdr-histogram-percentiles-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz", + "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", + "dev": true + }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", + "optional": true + }, + "node_modules/hosted-git-info": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz", + "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==", + "dev": true, + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.3.tgz", + "integrity": "sha512-C7FfFoTA+bI10qfeydT8aZbvr91vAEU+2W5BZUlzPec47oNb07SsOfwYrtxuvOYdUApPP/Qlh4DtAO51Ekk2QA==", + "dev": true, + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true + }, + "node_modules/immutable": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.0.tgz", + "integrity": "sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.0.0.tgz", + "integrity": "sha512-t0ikzf5qkSFqRl1e6ejKBe+Tk2bsQd8ivEkcisyGXsku2t8NvXZ1Y3RRz5vxrDgOrTBOi13CvGsVoI5wVpd7xg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/injection-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/injection-js/-/injection-js-2.4.0.tgz", + "integrity": "sha512-6jiJt0tCAo9zjHbcwLiPL+IuNe9SQ6a9g0PEzafThW3fOQi0mrmiJGBJvDD6tmhPh8cQHIQtCOrJuBfQME4kPA==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + } + }, + "node_modules/inquirer": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.4.tgz", + "integrity": "sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/inquirer/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/inquirer/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "dev": true + }, + "node_modules/ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-builtin-module/node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", + "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "dependencies": { + "is-path-inside": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "dependencies": { + "path-is-inside": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-what": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", + "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", + "dev": true + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/isbinaryfile": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.8.tgz", + "integrity": "sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/jackspeak": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.0.tgz", + "integrity": "sha512-r5XBrqIJfwRIjRt/Xr5fv9Wh09qyhHfKnYddDlpM+ibRR20qrYActpCAgU6U+d53EOEjzkvxPMVHSlgR7leXrQ==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.8.6", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.6.tgz", + "integrity": "sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA==", + "dev": true, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jake/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jake/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jake/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jake/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jasmine": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-2.8.0.tgz", + "integrity": "sha1-awicChFXax8W3xG4AUbZHU6Lij4=", + "dev": true, + "dependencies": { + "exit": "^0.1.2", + "glob": "^7.0.6", + "jasmine-core": "~2.8.0" + }, + "bin": { + "jasmine": "bin/jasmine.js" + } + }, + "node_modules/jasmine-core": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.0.0.tgz", + "integrity": "sha512-BJLxZlSVyWPN/oyaS1IIvIjChghI9/xWsLAIJqL9J5Fz47CN3JNr8Lmik3S2S7QS2RxclYjvSVSXP7IR35PAmg==", + "dev": true + }, + "node_modules/jasmine-spec-reporter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz", + "integrity": "sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==", + "dev": true, + "dependencies": { + "colors": "1.4.0" + } + }, + "node_modules/jasmine/node_modules/jasmine-core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.8.0.tgz", + "integrity": "sha1-vMl5rh+f0FcB5F5S5l06XWPxok4=", + "dev": true + }, + "node_modules/jasminewd2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jasminewd2/-/jasminewd2-2.2.0.tgz", + "integrity": "sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4=", + "dev": true, + "engines": { + "node": ">= 6.9.x" + } + }, + "node_modules/jest-worker": { + "version": "27.4.6", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.4.6.tgz", + "integrity": "sha512-gHWJF/6Xi5CTG5QCvROr6GcmpIqNYpDJyc8A1h/DyXqH1tD6SnRCM0d3U5msV31D2LB/U+E0M+W4oyvKV44oNw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", + "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-sdsl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", + "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml-loader": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/js-yaml-loader/-/js-yaml-loader-1.2.2.tgz", + "integrity": "sha512-H+NeuNrG6uOs/WMjna2SjkaCw13rMWiT/D7l9+9x5n8aq88BDsh2sRmdfxckWPIHtViYHWRG6XiCKYvS1dfyLg==", + "dev": true, + "dependencies": { + "js-yaml": "^3.13.1", + "loader-utils": "^1.2.3", + "un-eval": "^1.2.0" + } + }, + "node_modules/js-yaml-loader/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/js-yaml-loader/node_modules/loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dev": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/karma": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", + "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.4.1", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "dev": true, + "dependencies": { + "source-map-support": "^0.5.5" + } + }, + "node_modules/karma/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/karma/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/karma/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/karma/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/karma/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/katex": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.0.tgz", + "integrity": "sha512-wPRB4iUPysfH97wTgG5/tRLYxmKVq6Q4jRAWRVOUxXB1dsiv4cvcNjqabHkrOvJHM1Bpk3WrgmllSO1vIvP24w==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "optional": true, + "dependencies": { + "commander": "^8.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.0.0.tgz", + "integrity": "sha512-2J8rDNlQWbtiNYThZRvmMv5yt44ZakX+Tz5ZIp/mN1pt4snn+m030Va5Z4v8xA0cQFDXBwO/8i42xL4QPsVk3g==", + "optional": true + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/launch-editor": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.0.tgz", + "integrity": "sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==", + "dev": true, + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.7.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "optional": true + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/less": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz", + "integrity": "sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==", + "dev": true, + "dependencies": { + "copy-anything": "^2.0.1", + "parse-node-version": "^1.0.1", + "tslib": "^2.3.0" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=6" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" + } + }, + "node_modules/less-loader": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-11.1.0.tgz", + "integrity": "sha512-C+uDBV7kS7W5fJlUjq5mPBeBVhYpTIm5gB09APT9o3n/ILeaXVsiSFTbZpTJCJwQ/Crczfn3DmfQFwxYusWFug==", + "dev": true, + "dependencies": { + "klona": "^2.0.4" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" + } + }, + "node_modules/less/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "optional": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/less/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/less/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/less/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "dev": true, + "dependencies": { + "webpack-sources": "^3.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/loader-runner": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", + "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "devOptional": true + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "optional": true + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log4js": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.6.0.tgz", + "integrity": "sha512-3v8R7fd45UB6THucSht6wN2/7AZEruQbXdjygPZcxt5TA/msO6si9CN5MefUuKXbYnJHTBnYcx4famwcyQd+sA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "date-format": "^4.0.11", + "debug": "^4.3.4", + "flatted": "^3.2.5", + "rfdc": "^1.3.0", + "streamroller": "^3.1.1" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/make-fetch-happen/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/make-fetch-happen/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-fetch-happen/node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.1.tgz", + "integrity": "sha512-UWbFJKvj5k+nETdteFndTpYxdeTMox/ULeqX5k/dpaQJCCFmj5EeKv3dBcyO2xmkRAx2vppRu5dVG7SOtsGOzA==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-9.4.3.tgz", + "integrity": "sha512-TLkQEtqhRSuEHSE34lh5bCa94KATCyluAXmFnNI2PRZwOpXFeqiJWwZl+d2CcemE1RS6QbbueSSq9QIg8Uxcyw==", + "optional": true, + "dependencies": { + "@braintree/sanitize-url": "^6.0.0", + "cytoscape": "^3.23.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.1.0", + "d3": "^7.4.0", + "dagre-d3-es": "7.0.9", + "dayjs": "^1.11.7", + "dompurify": "2.4.3", + "elkjs": "^0.8.2", + "khroma": "^2.0.0", + "lodash-es": "^4.17.21", + "non-layered-tidy-tree-layout": "^2.0.2", + "stylis": "^4.1.2", + "ts-dedent": "^2.2.0", + "uuid": "^9.0.0", + "web-worker": "^1.2.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", + "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.34", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", + "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "dev": true, + "dependencies": { + "mime-db": "1.51.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.5.tgz", + "integrity": "sha512-9HaR++0mlgom81s95vvNjxkg52n2b5s//3ZTI1EtzFb98awsLSivs2LMsVqnQ3ay0PVhqWcGNyDaTE961FOcjQ==", + "dev": true, + "dependencies": { + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-json-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", + "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", + "dev": true, + "dependencies": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + } + }, + "node_modules/minipass-json-stream/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true + }, + "node_modules/needle": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.2.0.tgz", + "integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==", + "dev": true, + "optional": true, + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "optional": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/ng-packagr": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-16.0.1.tgz", + "integrity": "sha512-MiJvSR+8olzCViwkQ6ihHLFWVNLdsfUNPCxrZqR7u1nOC/dXlWPf//l2IG0KLdVhHNCiM64mNdwaTpgDEBMD3w==", + "dev": true, + "dependencies": { + "@rollup/plugin-json": "^6.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "ajv": "^8.11.0", + "ansi-colors": "^4.1.3", + "autoprefixer": "^10.4.12", + "browserslist": "^4.21.4", + "cacache": "^17.0.0", + "chokidar": "^3.5.3", + "commander": "^10.0.0", + "convert-source-map": "^2.0.0", + "dependency-graph": "^0.11.0", + "esbuild-wasm": "^0.17.0", + "fast-glob": "^3.2.12", + "find-cache-dir": "^3.3.2", + "injection-js": "^2.4.0", + "jsonc-parser": "^3.2.0", + "less": "^4.1.3", + "ora": "^5.1.0", + "piscina": "^3.2.0", + "postcss": "^8.4.16", + "postcss-url": "^10.1.3", + "rollup": "^3.0.0", + "rxjs": "^7.5.6", + "sass": "^1.55.0" + }, + "bin": { + "ng-packagr": "cli/main.js" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0" + }, + "optionalDependencies": { + "esbuild": "^0.17.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^16.0.0-next.0", + "tailwindcss": "^2.0.0 || ^3.0.0", + "tslib": "^2.3.0", + "typescript": ">=4.9.3 <5.1" + }, + "peerDependenciesMeta": { + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/ng-packagr/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/ng-packagr/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/ng-zorro-antd": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/ng-zorro-antd/-/ng-zorro-antd-16.1.0.tgz", + "integrity": "sha512-+KjXoA0+v/liTtVIHswmOAzB9UaGADrO1tL9AOZsTLq5sZM8+DmhtixGRoSMD8HkkhpMFhsgEIxoHlkxtn1SXg==", + "dependencies": { + "@angular/cdk": "^16.0.0", + "@ant-design/icons-angular": "^16.0.0", + "date-fns": "^2.16.1", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/animations": "^16.0.0", + "@angular/common": "^16.0.0", + "@angular/core": "^16.0.0", + "@angular/forms": "^16.0.0", + "@angular/platform-browser": "^16.0.0", + "@angular/router": "^16.0.0" + } + }, + "node_modules/ngx-markdown": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-16.0.0.tgz", + "integrity": "sha512-/rlbXi+HBscJCDdwaTWIUrRkvwJicPnuAgeugOCZa0UbZ4VCWV3U0+uB1Zv6krRDF6FXJNXNLTUrMZV7yH8I6A==", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "clipboard": "^2.0.11", + "emoji-toolkit": "^7.0.0", + "katex": "^0.16.0", + "mermaid": "^9.1.2", + "prismjs": "^1.28.0" + }, + "peerDependencies": { + "@angular/common": "^16.0.0", + "@angular/core": "^16.0.0", + "@angular/platform-browser": "^16.0.0", + "@types/marked": "^4.3.0", + "marked": "^4.3.0", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.13.0" + } + }, + "node_modules/nice-napi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", + "integrity": "sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "!win32" + ], + "dependencies": { + "node-addon-api": "^3.0.0", + "node-gyp-build": "^4.2.2" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "dev": true + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-gyp": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.3.1.tgz", + "integrity": "sha512-4Q16ZCqq3g8awk6UplT7AuxQ35XN4R/yf/+wSAwcBUAjg7l58RTactWaP8fIDTi0FzI7YcVLujwExakZlfWkXg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp-build": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", + "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", + "dev": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/node-releases": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", + "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==" + }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "optional": true + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz", + "integrity": "sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==", + "dev": true, + "dependencies": { + "hosted-git-info": "^6.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", + "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "dev": true, + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-install-checks": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.1.1.tgz", + "integrity": "sha512-dH3GmQL4vsPtld59cOn8uY0iOqRmqKvV+DLGwNXV/Q7MDgD2QfOADWd/mFXcIE5LVhYYGjA3baz6W9JneqnuCw==", + "dev": true, + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-10.1.0.tgz", + "integrity": "sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-packlist": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-7.0.4.tgz", + "integrity": "sha512-d6RGEuRrNS5/N84iglPivjaJPxhDbZmlbTwTDX2IbcRHG5bZCdtysYMhwiPvcF4GisXHGn7xsxv+GQ7T/02M5Q==", + "dev": true, + "dependencies": { + "ignore-walk": "^6.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-8.0.1.tgz", + "integrity": "sha512-mRtvlBjTsJvfCCdmPtiu2bdlx8d/KXtF7yNXNWe7G0Z36qWA9Ny5zXsI2PfBZEv7SXgoxTmNaTzGSbbzDZChoA==", + "dev": true, + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^10.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-14.0.5.tgz", + "integrity": "sha512-kIDMIo4aBm6xg7jOttupWZamsZRkAqMqwqqbVXnUqstY5+tapvv6bkH/qMR76jdgV+YljEUCyWx3hRYMrJiAgA==", + "dev": true, + "dependencies": { + "make-fetch-happen": "^11.0.0", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^10.0.0", + "proc-log": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm-registry-fetch/node_modules/make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch/node_modules/minipass-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.3.tgz", + "integrity": "sha512-n5ITsTkDqYkYJZjcRWzZt9qnZKCT7nKCosJhHoj7S7zD+BP4jVbWs+odsniw5TA3E0sLomhTKOKjF86wf11PuQ==", + "dev": true, + "dependencies": { + "minipass": "^5.0.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/npm-run-all/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "dev": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nx": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/nx/-/nx-16.0.2.tgz", + "integrity": "sha512-8Z9Bo1D2VbYjyC/F2ONensKjm10snz1UfkzURZiFA+oXikBPldiH1u67TOTpoCYZfyYQg4l6h6EpOaAvHF6Abg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@nrwl/tao": "16.0.2", + "@parcel/watcher": "2.0.4", + "@yarnpkg/lockfile": "^1.1.0", + "@yarnpkg/parsers": "^3.0.0-rc.18", + "@zkochan/js-yaml": "0.0.6", + "axios": "^1.0.0", + "chalk": "^4.1.0", + "cli-cursor": "3.1.0", + "cli-spinners": "2.6.1", + "cliui": "^7.0.2", + "dotenv": "~10.0.0", + "enquirer": "~2.3.6", + "fast-glob": "3.2.7", + "figures": "3.2.0", + "flat": "^5.0.2", + "fs-extra": "^11.1.0", + "glob": "7.1.4", + "ignore": "^5.0.4", + "js-yaml": "4.1.0", + "jsonc-parser": "3.2.0", + "lines-and-columns": "~2.0.3", + "minimatch": "3.0.5", + "npm-run-path": "^4.0.1", + "open": "^8.4.0", + "semver": "7.3.4", + "string-width": "^4.2.3", + "strong-log-transformer": "^2.1.0", + "tar-stream": "~2.2.0", + "tmp": "~0.2.1", + "tsconfig-paths": "^4.1.2", + "tslib": "^2.3.0", + "v8-compile-cache": "2.3.0", + "yargs": "^17.6.2", + "yargs-parser": "21.1.1" + }, + "bin": { + "nx": "bin/nx.js" + }, + "optionalDependencies": { + "@nx/nx-darwin-arm64": "16.0.2", + "@nx/nx-darwin-x64": "16.0.2", + "@nx/nx-linux-arm-gnueabihf": "16.0.2", + "@nx/nx-linux-arm64-gnu": "16.0.2", + "@nx/nx-linux-arm64-musl": "16.0.2", + "@nx/nx-linux-x64-gnu": "16.0.2", + "@nx/nx-linux-x64-musl": "16.0.2", + "@nx/nx-win32-arm64-msvc": "16.0.2", + "@nx/nx-win32-x64-msvc": "16.0.2" + }, + "peerDependencies": { + "@swc-node/register": "^1.4.2", + "@swc/core": "^1.2.173" + }, + "peerDependenciesMeta": { + "@swc-node/register": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/nx/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/nx/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/nx/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/nx/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/nx/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/nx/node_modules/fast-glob": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", + "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nx/node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/nx/node_modules/glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nx/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nx/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/nx/node_modules/lines-and-columns": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", + "integrity": "sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/nx/node_modules/semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nx/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nx/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/ora/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pacote": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.1.3.tgz", + "integrity": "sha512-aRts8cZqxiJVDitmAh+3z+FxuO3tLNWEmwDRPEpDDiZJaRz06clP4XX112ynMT5uF0QNoMPajBBHnaStUEPJXA==", + "dev": true, + "dependencies": { + "@npmcli/git": "^4.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/promise-spawn": "^6.0.1", + "@npmcli/run-script": "^6.0.0", + "cacache": "^17.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^5.0.0", + "npm-package-arg": "^10.0.0", + "npm-packlist": "^7.0.0", + "npm-pick-manifest": "^8.0.0", + "npm-registry-fetch": "^14.0.0", + "proc-log": "^3.0.0", + "promise-retry": "^2.0.1", + "read-package-json": "^6.0.0", + "read-package-json-fast": "^3.0.0", + "sigstore": "^1.3.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "lib/bin.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "devOptional": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", + "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", + "dev": true, + "dependencies": { + "entities": "^4.3.0", + "parse5": "^7.0.0", + "parse5-sax-parser": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "node_modules/parse5-sax-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", + "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", + "dev": true, + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "devOptional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.9.1.tgz", + "integrity": "sha512-UgmoiySyjFxP6tscZDgWGEAgsW5ok8W3F5CJDnnH2pozwSTGE6eH7vwTotMwATWA2r5xqdkKdxYPkwlJjAI/3g==", + "dev": true, + "dependencies": { + "lru-cache": "^9.1.1", + "minipass": "^5.0.0 || ^6.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz", + "integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", + "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/piscina": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-3.2.0.tgz", + "integrity": "sha512-yn/jMdHRw+q2ZJhFhyqsmANcbF6V2QwmD84c6xRau+QpQOmtrBCoRGdvTfeuFDYXB5W2m6MfLkjkvQa9lUSmIA==", + "dev": true, + "dependencies": { + "eventemitter-asyncresource": "^1.0.0", + "hdr-histogram-js": "^2.0.1", + "hdr-histogram-percentiles-obj": "^3.0.0" + }, + "optionalDependencies": { + "nice-napi": "^1.0.2" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", + "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dev": true, + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-loader": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.0.tgz", + "integrity": "sha512-qLAFjvR2BFNz1H930P7mj1iuWJFjGey/nVhimfOAAQ1ZyPpcClAxP8+A55Sl8mBvM+K2a9Pjgdj10KpANWrNfw==", + "dev": true, + "dependencies": { + "cosmiconfig": "^8.1.3", + "jiti": "^1.18.2", + "klona": "^2.0.6", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.6.tgz", + "integrity": "sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + } + ], + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.19" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-url": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.3.tgz", + "integrity": "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==", + "dev": true, + "dependencies": { + "make-dir": "~3.1.0", + "mime": "~2.5.2", + "minimatch": "~3.0.4", + "xxhashjs": "~0.2.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-url/node_modules/mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/proc-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", + "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/protractor": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz", + "integrity": "sha512-UqkFjivi4GcvUQYzqGYNe0mLzfn5jiLmO8w9nMhQoJRLhy2grJonpga2IWhI6yJO30LibWXJJtA4MOIZD2GgZw==", + "dev": true, + "dependencies": { + "@types/q": "^0.0.32", + "@types/selenium-webdriver": "^3.0.0", + "blocking-proxy": "^1.0.0", + "browserstack": "^1.5.1", + "chalk": "^1.1.3", + "glob": "^7.0.3", + "jasmine": "2.8.0", + "jasminewd2": "^2.1.0", + "q": "1.4.1", + "saucelabs": "^1.5.0", + "selenium-webdriver": "3.6.0", + "source-map-support": "~0.4.0", + "webdriver-js-extender": "2.1.0", + "webdriver-manager": "^12.1.7", + "yargs": "^15.3.1" + }, + "bin": { + "protractor": "bin/protractor", + "webdriver-manager": "bin/webdriver-manager" + }, + "engines": { + "node": ">=10.13.x" + } + }, + "node_modules/protractor/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protractor/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protractor/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protractor/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/protractor/node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/protractor/node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/protractor/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/protractor/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/protractor/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protractor/node_modules/source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "dependencies": { + "source-map": "^0.5.6" + } + }, + "node_modules/protractor/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protractor/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/protractor/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/protractor/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/protractor/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/protractor/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/protractor/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/protractor/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/protractor/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "optional": true + }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/purgecss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-5.0.0.tgz", + "integrity": "sha512-RAnuxrGuVyLLTr8uMbKaxDRGWMgK5CCYDfRyUNNcaz5P3kGgD2b7ymQGYEyo2ST7Tl/ScwFgf5l3slKMxHSbrw==", + "dev": true, + "dependencies": { + "commander": "^9.0.0", + "glob": "^8.0.3", + "postcss": "^8.4.4", + "postcss-selector-parser": "^6.0.7" + }, + "bin": { + "purgecss": "bin/purgecss.js" + } + }, + "node_modules/purgecss/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/purgecss/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/purgecss/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/purgecss/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/q": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz", + "integrity": "sha1-VXBbzZPF82c1MMLCy8DCs63cKG4=", + "dev": true, + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.9" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-package-json": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-6.0.3.tgz", + "integrity": "sha512-4QbpReW4kxFgeBQ0vPAqh2y8sXEB3D4t3jsXbJKIhBiF80KT6XRo45reqwtftju5J6ru1ax06A2Gb/wM1qCOEQ==", + "dev": true, + "dependencies": { + "glob": "^10.2.2", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^5.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", + "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", + "dev": true, + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", + "integrity": "sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/read-package-json/node_modules/glob": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.4.tgz", + "integrity": "sha512-fDboBse/sl1oXSLhIp0FcCJgzW9KmhC/q8ULTKC82zc+DL3TL7FNb8qlt5qqXN53MsKEUSIcb+7DLmEygOE5Yw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.0", + "minipass": "^5.0.0 || ^6.0.0", + "path-scurry": "^1.7.0" + }, + "bin": { + "glob": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/read-package-json/node_modules/json-parse-even-better-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", + "integrity": "sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json/node_modules/minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.2.tgz", + "integrity": "sha512-6RLVvwJtVwEDfPdn6X6Ille4/lxGl0ATOY4FN/B9nxQcgOazvvI0nodiD19ScKq0PvA/29VpaOQML36o5IzZWA==", + "dev": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", + "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex-parser": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", + "dev": true + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dev": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", + "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "dev": true, + "dependencies": { + "is-core-module": "^2.11.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "dev": true, + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/resolve-url-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.1.tgz", + "integrity": "sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==" + }, + "node_modules/rollup": { + "version": "3.21.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.21.8.tgz", + "integrity": "sha512-SSFV2T2fWtQ/vvBip85u2Nr0GNKireabH9d7nXswBg+XSH+jbVDSYptRAEbCEsquhs503rpPA9POYAp0/Jhasw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sass": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.62.1.tgz", + "integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.2.2.tgz", + "integrity": "sha512-nrIdVAAte3B9icfBiGWvmMhT/D+eCDwnk+yA7VE/76dp/WkHX+i44Q/pfo71NYbwj0Ap+PGsn0ekOuU1WFJ2AA==", + "dev": true, + "dependencies": { + "klona": "^2.0.6", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "node_modules/saucelabs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/saucelabs/-/saucelabs-1.5.0.tgz", + "integrity": "sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ==", + "dev": true, + "dependencies": { + "https-proxy-agent": "^2.2.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/saucelabs/node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dev": true, + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/saucelabs/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/saucelabs/node_modules/https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "dev": true, + "dependencies": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "node_modules/schema-utils": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", + "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "optional": true + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "node_modules/selenium-webdriver": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-3.6.0.tgz", + "integrity": "sha512-WH7Aldse+2P5bbFBO4Gle/nuQOdVwpHMTL6raL3uuBj/vPG07k6uzt3aiahu352ONBr5xXh0hDlM3LhtXPOC4Q==", + "dev": true, + "dependencies": { + "jszip": "^3.1.3", + "rimraf": "^2.5.4", + "tmp": "0.0.30", + "xml2js": "^0.4.17" + }, + "engines": { + "node": ">= 6.9.0" + } + }, + "node_modules/selenium-webdriver/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/selenium-webdriver/node_modules/tmp": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz", + "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/selfsigned": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", + "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dev": true, + "dependencies": { + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", + "integrity": "sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "node_modules/set-function-length": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sigstore": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-1.5.1.tgz", + "integrity": "sha512-FIPThk7S1oeFXn8O8yh7gpyiQb6lYXzMIlOBzXhId/f81VvU587xNCHc4jd2lZ9724UkKUYYTuKSYcjhDSRD/Q==", + "dev": true, + "dependencies": { + "@sigstore/protobuf-specs": "^0.1.0", + "make-fetch-happen": "^11.0.1", + "tuf-js": "^1.1.3" + }, + "bin": { + "sigstore": "bin/sigstore.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/sigstore/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/sigstore/node_modules/make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/sigstore/node_modules/minipass-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.3.tgz", + "integrity": "sha512-n5ITsTkDqYkYJZjcRWzZt9qnZKCT7nKCosJhHoj7S7zD+BP4jVbWs+odsniw5TA3E0sLomhTKOKjF86wf11PuQ==", + "dev": true, + "dependencies": { + "minipass": "^5.0.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/sirv": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz", + "integrity": "sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^1.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.6.1.tgz", + "integrity": "sha512-KMcaAi4l/8+xEjkRICl6ak8ySoxsYG+gG6/XfRCPJPQ/haCRIJBTL4wIl8YCsmtaBovcAXGLOShyVWQ/FG8GZA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.2", + "engine.io": "~6.4.1", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "ws": "~8.11.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz", + "integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dev": true, + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-4.0.1.tgz", + "integrity": "sha512-oqXpzDIByKONVY8g1NUPOTQhe0UTU5bWUl32GSkqK2LjJj0HmwTMVKxcUip0RgAYhY1mqgOxjbQM48a0mmeNfA==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", + "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "dev": true + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sshpk": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", + "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "dev": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssri": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.4.tgz", + "integrity": "sha512-12+IR2CB2C28MMAw0Ncqwj5QbTcs0nGIhgJzYWzDkb21vWmfNI83KS4f3Ci6GI98WreIfG7o9UXp3C0qbpA8nQ==", + "dev": true, + "dependencies": { + "minipass": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "dependencies": { + "internal-slot": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamroller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.1.tgz", + "integrity": "sha512-iPhtd9unZ6zKdWgMeYGfSBuqCngyJy1B/GPi/lTpwGpa3bajuX30GjUVd0/Tn/Xhg0mr4DOSENozz9Y06qyonQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "date-format": "^4.0.10", + "debug": "^4.3.4", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strong-log-transformer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz", + "integrity": "sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA==", + "dev": true, + "dependencies": { + "duplexer": "^0.1.1", + "minimist": "^1.2.0", + "through": "^2.3.4" + }, + "bin": { + "sl-log-transformer": "bin/sl-log-transformer.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "optional": true + }, + "node_modules/stylus": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.59.0.tgz", + "integrity": "sha512-lQ9w/XIOH5ZHVNuNbWW8D822r+/wBSO/d6XvtyHLF7LW4KaCIDeVbvn5DF8fGCJAUCwVhVi/h6J0NUcnylUEjg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "debug": "^4.3.2", + "glob": "^7.1.6", + "sax": "~1.2.4", + "source-map": "^0.7.3" + }, + "bin": { + "stylus": "bin/stylus" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://opencollective.com/stylus" + } + }, + "node_modules/sucrase": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.32.0.tgz", + "integrity": "sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", + "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "postcss-value-parser": "^4.2.0", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "6.1.14", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.14.tgz", + "integrity": "sha512-piERznXu0U7/pW7cdSn7hjqySIVTYT6F76icmFk7ptU7dDYlXTm5r9A6K04R2vU3olYgoKeo1Cg3eeu5nhftAw==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/terser": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.1.tgz", + "integrity": "sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz", + "integrity": "sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.16.8" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/terser": { + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", + "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/acorn": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", + "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "optional": true + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-simplify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/topojson-simplify/-/topojson-simplify-3.0.3.tgz", + "integrity": "sha512-V+pBjLVzSQ3+hSOxBiV01OVXgFiCmMO8ia3huxKEyIMTC1ApQHBcdXdOqcQ6U2JJJD31TZduwY6KyF15R8sUgg==", + "dependencies": { + "commander": "2", + "topojson-client": "3" + }, + "bin": { + "toposimplify": "bin/toposimplify" + } + }, + "node_modules/totalist": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", + "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "optional": true, + "engines": { + "node": ">=6.10" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, + "node_modules/tslint": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz", + "integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==", + "deprecated": "TSLint has been deprecated in favor of ESLint. Please see https://github.com/palantir/tslint/issues/4534 for more information.", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^4.0.1", + "glob": "^7.1.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.3", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.13.0", + "tsutils": "^2.29.0" + }, + "bin": { + "tslint": "bin/tslint" + }, + "engines": { + "node": ">=4.8.0" + }, + "peerDependencies": { + "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev" + } + }, + "node_modules/tslint/node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/tslint/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tslint/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "peerDependencies": { + "typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/tuf-js": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-1.1.6.tgz", + "integrity": "sha512-CXwFVIsXGbVY4vFiWF7TJKWmlKJAT8TWkH4RmiohJRcDJInix++F0dznDmoVbtJNzZ8yLprKUG4YrDIhv3nBMg==", + "dev": true, + "dependencies": { + "@tufjs/models": "1.0.4", + "debug": "^4.3.4", + "make-fetch-happen": "^11.1.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/tuf-js/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/tuf-js/node_modules/make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/tuf-js/node_modules/minipass-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.3.tgz", + "integrity": "sha512-n5ITsTkDqYkYJZjcRWzZt9qnZKCT7nKCosJhHoj7S7zD+BP4jVbWs+odsniw5TA3E0sLomhTKOKjF86wf11PuQ==", + "dev": true, + "dependencies": { + "minipass": "^5.0.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.5.tgz", + "integrity": "sha512-yMi0PlwuznKHxKmcpoOdeLwxBoVPkqZxd7q2FgMkmD3bNwvF5VW0+UlUQ1k1vmktTu4Yu13Q0RIxEP8+B+wloA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-assert": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", + "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/un-eval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/un-eval/-/un-eval-1.2.0.tgz", + "integrity": "sha512-Wlj/pum6dQtGTPD/lclDtoVPkSfpjPfy1dwnnKw/sZP5DpBH9fLhBgQfsqNhe5/gS1D+vkZUuB771NRMUPA5CA==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", + "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/useragent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.3.0.tgz", + "integrity": "sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==", + "dev": true, + "dependencies": { + "lru-cache": "4.1.x", + "tmp": "0.0.x" + } + }, + "node_modules/useragent/node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/useragent/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", + "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", + "dev": true, + "dependencies": { + "builtins": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==", + "dev": true, + "dependencies": { + "esbuild": "^0.17.5", + "postcss": "^8.4.21", + "rollup": "^3.20.2" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-worker": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", + "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "optional": true + }, + "node_modules/webdriver-js-extender": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", + "integrity": "sha512-lcUKrjbBfCK6MNsh7xaY2UAUmZwe+/ib03AjVOpFobX4O7+83BUveSrLfU0Qsyb1DaKJdQRbuU+kM9aZ6QUhiQ==", + "dev": true, + "dependencies": { + "@types/selenium-webdriver": "^3.0.0", + "selenium-webdriver": "^3.0.1" + }, + "engines": { + "node": ">=6.9.x" + } + }, + "node_modules/webdriver-manager": { + "version": "12.1.9", + "resolved": "https://registry.npmjs.org/webdriver-manager/-/webdriver-manager-12.1.9.tgz", + "integrity": "sha512-Yl113uKm8z4m/KMUVWHq1Sjtla2uxEBtx2Ue3AmIlnlPAKloDn/Lvmy6pqWCUersVISpdMeVpAaGbNnvMuT2LQ==", + "dev": true, + "dependencies": { + "adm-zip": "^0.5.2", + "chalk": "^1.1.1", + "del": "^2.2.0", + "glob": "^7.0.3", + "ini": "^1.3.4", + "minimist": "^1.2.0", + "q": "^1.4.1", + "request": "^2.87.0", + "rimraf": "^2.5.2", + "semver": "^5.3.0", + "xml2js": "^0.4.17" + }, + "bin": { + "webdriver-manager": "bin/webdriver-manager" + }, + "engines": { + "node": ">=6.9.x" + } + }, + "node_modules/webdriver-manager/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webdriver-manager/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webdriver-manager/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webdriver-manager/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/webdriver-manager/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/webdriver-manager/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/webdriver-manager/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webdriver-manager/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/webextension-polyfill": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.8.0.tgz", + "integrity": "sha512-a19+DzlT6Kp9/UI+mF9XQopeZ+n2ussjhxHJ4/pmIGge9ijCDz7Gn93mNnjpZAk95T4Tae8iHZ6sSf869txqiQ==", + "dev": true + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack": { + "version": "5.80.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.80.0.tgz", + "integrity": "sha512-OIMiq37XK1rWO8mH9ssfFKZsXg4n6klTEDL7S8/HqbAOBBaiy8ABvXvz0dDCXeEF9gqwxSvVk611zFPjS8hJxA==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.13.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.8.0.tgz", + "integrity": "sha512-ZzoSBePshOKhr+hd8u6oCkZVwpVaXgpw23ScGLFpR6SjYI7+7iIWYarjN6OEYOfRt8o7ZyZZQk0DuMizJ+LEIg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "opener": "^1.5.2", + "sirv": "^1.0.7", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.6.tgz", + "integrity": "sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==", + "dev": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.0.2.tgz", + "integrity": "sha512-iOddiJzPcQC6lwOIu60vscbGWth8PCRcWRCwoQcTQf9RMoOWBHg5EyzpGdtSmGMrSPd5vHEfFXmVErQEmkRngQ==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.12", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.13.2.tgz", + "integrity": "sha512-5i6TrGBRxG4vnfDpB6qSQGfnB6skGBXNL5/542w2uRGLimX6qeE5BQMLrzIC3JYV/xlGOv+s+hTleI9AZKUQNw==", + "dev": true, + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-ext-reloader": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/webpack-ext-reloader/-/webpack-ext-reloader-1.1.9.tgz", + "integrity": "sha512-6AVXGrjcVHKtIQn4yGGghJpiIV2h9F7hNKLsh1oP8m+d6H3QLF3jTNu3vNdKu/8Lab3J/gwb7Bm7tjZLa+DS6g==", + "dev": true, + "dependencies": { + "@types/webextension-polyfill": "^0.8.2", + "@types/webpack": "^5.28.0", + "@types/webpack-sources": "^3.2.0", + "clean-webpack-plugin": "^4.0.0", + "colors": "^1.4.0", + "cross-env": "^7.0.3", + "lodash": "^4.17.21", + "minimist": "^1.2.5", + "useragent": "^2.3.0", + "webextension-polyfill": "^0.8.0", + "webpack-sources": "^3.2.3", + "ws": "^8.4.2" + }, + "bin": { + "webpack-ext-reloader": "dist/webpack-ext-reloader-cli.js" + }, + "peerDependencies": { + "webpack": "^5.61.0" + } + }, + "node_modules/webpack-merge": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", + "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-subresource-integrity": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", + "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", + "dev": true, + "dependencies": { + "typed-assert": "^1.0.8" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", + "webpack": "^5.12.0" + }, + "peerDependenciesMeta": { + "html-webpack-plugin": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack/node_modules/acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.2.tgz", + "integrity": "sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "node_modules/which-typed-array": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wildcard": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", + "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", + "dev": true + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dev": true, + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xxhashjs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", + "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "dev": true, + "dependencies": { + "cuint": "^0.2.2" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yaml": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz", + "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-a-folder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zip-a-folder/-/zip-a-folder-1.1.5.tgz", + "integrity": "sha512-w6I4mvWc6D0Q4pdzCSFbQih/ezYBdjwGZVbWRRFMOYcOdtE9TONZ7YtXCPnHj4XJQmXQxTOWcRGnPYxRn+d0mw==", + "dev": true, + "dependencies": { + "archiver": "^5.3.1" + } + }, + "node_modules/zip-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz", + "integrity": "sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==", + "dev": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "compress-commons": "^4.1.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zone.js": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.13.0.tgz", + "integrity": "sha512-7m3hNNyswsdoDobCkYNAy5WiUulkMd3+fWaGT9ij6iq3Zr/IwJo4RMCYPSDjT+r7tnPErmY9sZpKhWQ8S5k6XQ==", + "dependencies": { + "tslib": "^2.3.0" + } + } + }, + "dependencies": { + "@adobe/css-tools": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", + "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==", + "dev": true, + "optional": true, + "peer": true + }, + "@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true + }, + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@angular-builders/custom-webpack": { + "version": "16.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-16.0.0-beta.1.tgz", + "integrity": "sha512-C0tpgKJt++ciJ2nXtP2+fHOgzHUNyk5Su7bgTKY3yWMWlC9YfUMOlXHvNnCRUDaLqxXTsxQjGp56o9hPNd5miA==", + "dev": true, + "requires": { + "@angular-devkit/architect": ">=0.1600.0 < 0.1700.0", + "@angular-devkit/build-angular": "^16.0.0", + "@angular-devkit/core": "^16.0.0", + "lodash": "^4.17.15", + "ts-node": "^10.0.0", + "tsconfig-paths": "^4.1.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "@angular-devkit/build-angular": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-16.0.2.tgz", + "integrity": "sha512-jh6ez6k1tPmLTQ8J2T0CY+aRqLbhCvaExH6pqB7q6/bkDItcLPrybDGfJf05F0dHvZPB2fQEK0xYz9i92POofQ==", + "dev": true, + "requires": { + "@ampproject/remapping": "2.2.1", + "@angular-devkit/architect": "0.1600.2", + "@angular-devkit/build-webpack": "0.1600.2", + "@angular-devkit/core": "16.0.2", + "@babel/core": "7.21.4", + "@babel/generator": "7.21.4", + "@babel/helper-annotate-as-pure": "7.18.6", + "@babel/helper-split-export-declaration": "7.18.6", + "@babel/plugin-proposal-async-generator-functions": "7.20.7", + "@babel/plugin-transform-async-to-generator": "7.20.7", + "@babel/plugin-transform-runtime": "7.21.4", + "@babel/preset-env": "7.21.4", + "@babel/runtime": "7.21.0", + "@babel/template": "7.20.7", + "@discoveryjs/json-ext": "0.5.7", + "@ngtools/webpack": "16.0.2", + "@vitejs/plugin-basic-ssl": "1.0.1", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.14", + "babel-loader": "9.1.2", + "babel-plugin-istanbul": "6.1.1", + "browserslist": "4.21.5", + "cacache": "17.0.6", + "chokidar": "3.5.3", + "copy-webpack-plugin": "11.0.0", + "critters": "0.0.16", + "css-loader": "6.7.3", + "esbuild": "0.17.18", + "esbuild-wasm": "0.17.18", + "glob": "8.1.0", + "https-proxy-agent": "5.0.1", + "inquirer": "8.2.4", + "jsonc-parser": "3.2.0", + "karma-source-map-support": "1.4.0", + "less": "4.1.3", + "less-loader": "11.1.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.2.1", + "magic-string": "0.30.0", + "mini-css-extract-plugin": "2.7.5", + "mrmime": "1.0.1", + "open": "8.4.2", + "ora": "5.4.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "2.3.1", + "piscina": "3.2.0", + "postcss": "8.4.23", + "postcss-loader": "7.2.4", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.1", + "sass": "1.62.1", + "sass-loader": "13.2.2", + "semver": "7.4.0", + "source-map-loader": "4.0.1", + "source-map-support": "0.5.21", + "terser": "5.17.1", + "text-table": "0.2.0", + "tree-kill": "1.2.2", + "tslib": "2.5.0", + "vite": "4.3.1", + "webpack": "5.80.0", + "webpack-dev-middleware": "6.0.2", + "webpack-dev-server": "4.13.2", + "webpack-merge": "5.8.0", + "webpack-subresource-integrity": "5.1.0" + } + }, + "@angular-devkit/build-webpack": { + "version": "0.1600.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1600.2.tgz", + "integrity": "sha512-B7EYoRMZOT3RcorxkXaHvMqwuNSttJCicZ99DmwBC41YlZOxpVVP6uM6wvYINGO0TMtu9bCmKkrSD8IC/hHetQ==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.1600.2", + "rxjs": "7.8.1" + } + }, + "@angular-devkit/core": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.2.tgz", + "integrity": "sha512-V4+t0BHO+QML9O2IiG2mJi8DtjeMOm4LAuG6tNDeiHZGAPOflvSPsKBtVl2JlXX/JxdLmyF4B6kRoAXRMKcwTg==", + "dev": true, + "requires": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", + "source-map": "0.7.4" + } + }, + "@ngtools/webpack": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.0.2.tgz", + "integrity": "sha512-8nPAOs2JLdMrAUf3sMkySzh66sPIkukO6HT8KVj726Dqm0Jtabjnxh0EI15Gkykj7HqH0Zw7/VyxpNQRfTA2UQ==", + "dev": true, + "requires": {} + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "postcss-loader": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.2.4.tgz", + "integrity": "sha512-F88rpxxNspo5hatIc+orYwZDtHFaVFOSIVAx+fBfJC1GmhWbVmPWtmg2gXKE1OxJbneOSGn8PWdIwsZFcruS+w==", + "dev": true, + "requires": { + "cosmiconfig": "^8.1.3", + "cosmiconfig-typescript-loader": "^4.3.0", + "klona": "^2.0.6", + "semver": "^7.3.8" + } + } + } + }, + "@angular-devkit/architect": { + "version": "0.1600.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.2.tgz", + "integrity": "sha512-2AOP3/dwLywcjkRr3ixR/lb0uBn1jzaMWwQR3o7ye3IuEA2sRtyWhUzsy6V7smKBKWPDIbXvX2TcqYZAJ87ccA==", + "dev": true, + "requires": { + "@angular-devkit/core": "16.0.2", + "rxjs": "7.8.1" + }, + "dependencies": { + "@angular-devkit/core": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.2.tgz", + "integrity": "sha512-V4+t0BHO+QML9O2IiG2mJi8DtjeMOm4LAuG6tNDeiHZGAPOflvSPsKBtVl2JlXX/JxdLmyF4B6kRoAXRMKcwTg==", + "dev": true, + "requires": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", + "source-map": "0.7.4" + } + } + } + }, + "@angular-devkit/build-angular": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-16.0.1.tgz", + "integrity": "sha512-VFhUViBfONOf6Ji4Lfkxlk+GN5l8Owm4Z0McqUIegrXsq3aSSStBBFdaDESpzhS6GIGqEBjjHMUQK8IlWT+EIQ==", + "dev": true, + "requires": { + "@ampproject/remapping": "2.2.1", + "@angular-devkit/architect": "0.1600.1", + "@angular-devkit/build-webpack": "0.1600.1", + "@angular-devkit/core": "16.0.1", + "@babel/core": "7.21.4", + "@babel/generator": "7.21.4", + "@babel/helper-annotate-as-pure": "7.18.6", + "@babel/helper-split-export-declaration": "7.18.6", + "@babel/plugin-proposal-async-generator-functions": "7.20.7", + "@babel/plugin-transform-async-to-generator": "7.20.7", + "@babel/plugin-transform-runtime": "7.21.4", + "@babel/preset-env": "7.21.4", + "@babel/runtime": "7.21.0", + "@babel/template": "7.20.7", + "@discoveryjs/json-ext": "0.5.7", + "@ngtools/webpack": "16.0.1", + "@vitejs/plugin-basic-ssl": "1.0.1", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.14", + "babel-loader": "9.1.2", + "babel-plugin-istanbul": "6.1.1", + "browserslist": "4.21.5", + "cacache": "17.0.6", + "chokidar": "3.5.3", + "copy-webpack-plugin": "11.0.0", + "critters": "0.0.16", + "css-loader": "6.7.3", + "esbuild": "0.17.18", + "esbuild-wasm": "0.17.18", + "glob": "8.1.0", + "https-proxy-agent": "5.0.1", + "inquirer": "8.2.4", + "jsonc-parser": "3.2.0", + "karma-source-map-support": "1.4.0", + "less": "4.1.3", + "less-loader": "11.1.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.2.1", + "magic-string": "0.30.0", + "mini-css-extract-plugin": "2.7.5", + "mrmime": "1.0.1", + "open": "8.4.2", + "ora": "5.4.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "2.3.1", + "piscina": "3.2.0", + "postcss": "8.4.23", + "postcss-loader": "7.2.4", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.1", + "sass": "1.62.1", + "sass-loader": "13.2.2", + "semver": "7.4.0", + "source-map-loader": "4.0.1", + "source-map-support": "0.5.21", + "terser": "5.17.1", + "text-table": "0.2.0", + "tree-kill": "1.2.2", + "tslib": "2.5.0", + "vite": "4.3.1", + "webpack": "5.80.0", + "webpack-dev-middleware": "6.0.2", + "webpack-dev-server": "4.13.2", + "webpack-merge": "5.8.0", + "webpack-subresource-integrity": "5.1.0" + }, + "dependencies": { + "@angular-devkit/architect": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.1.tgz", + "integrity": "sha512-7N3Dugrp3Fyyn3Q6RsxFNJJ2m1QuqcF3GHJcX7siINL37Hp6xI/q5gKffcd9rf20H1DYZE0VIbR1Sk31G6hMWg==", + "dev": true, + "requires": { + "@angular-devkit/core": "16.0.1", + "rxjs": "7.8.1" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "postcss-loader": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.2.4.tgz", + "integrity": "sha512-F88rpxxNspo5hatIc+orYwZDtHFaVFOSIVAx+fBfJC1GmhWbVmPWtmg2gXKE1OxJbneOSGn8PWdIwsZFcruS+w==", + "dev": true, + "requires": { + "cosmiconfig": "^8.1.3", + "cosmiconfig-typescript-loader": "^4.3.0", + "klona": "^2.0.6", + "semver": "^7.3.8" + } + } + } + }, + "@angular-devkit/build-webpack": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1600.1.tgz", + "integrity": "sha512-yCy5A1UwGzpst3QJ/CRo2Y8HWRqTPOfwAPAVl91Lbch7gBFViRvq6E7N1XfQunPu/eXvKxbuq2mFSDqtyZ1mWw==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.1600.1", + "rxjs": "7.8.1" + }, + "dependencies": { + "@angular-devkit/architect": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.1.tgz", + "integrity": "sha512-7N3Dugrp3Fyyn3Q6RsxFNJJ2m1QuqcF3GHJcX7siINL37Hp6xI/q5gKffcd9rf20H1DYZE0VIbR1Sk31G6hMWg==", + "dev": true, + "requires": { + "@angular-devkit/core": "16.0.1", + "rxjs": "7.8.1" + } + } + } + }, + "@angular-devkit/core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.1.tgz", + "integrity": "sha512-2uz98IqkKJlgnHbWQ7VeL4pb+snGAZXIama2KXi+k9GsRntdcw+udX8rL3G9SdUGUF+m6+147Y1oRBMHsO/v4w==", + "dev": true, + "requires": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", + "source-map": "0.7.4" + } + }, + "@angular-devkit/schematics": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-A9D0LTYmiqiBa90GKcSuWb7hUouGIbm/AHbJbjL85WLLRbQA2PwKl7P5Mpd6nS/ZC0kfG4VQY3VOaDvb3qpI9g==", + "dev": true, + "requires": { + "@angular-devkit/core": "16.0.1", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.0", + "ora": "5.4.1", + "rxjs": "7.8.1" + } + }, + "@angular-eslint/builder": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-16.0.1.tgz", + "integrity": "sha512-yjFltV+r3YjisVjASMPmWB/ASz39wdh0q5g0l6/4G+8yaxl6hEYs5o0ZOGeGdTFstCql8FGY+QKwKgsq9Ec4QQ==", + "dev": true, + "requires": { + "@nx/devkit": "16.0.2", + "nx": "16.0.2" + } + }, + "@angular-eslint/bundled-angular-compiler": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-16.0.1.tgz", + "integrity": "sha512-amvTgKHtZoygivW3LAYZ9qjLWsXM7/7eaRvaHdmAEdjyFnYQZ7UbWMPSQNz1mlW/AzTFvk9lGGQORglNOSDnww==", + "dev": true + }, + "@angular-eslint/eslint-plugin": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-16.0.1.tgz", + "integrity": "sha512-CM9keS9cH1QAfSVfsvhw/oGCZcP/D8gfekWwVNjN/uEMEAak0czn1KOG7JQkE36NXOGtwCpTspMi1aa9CVKo9g==", + "dev": true, + "requires": { + "@angular-eslint/utils": "16.0.1", + "@typescript-eslint/utils": "5.59.2" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" + } + }, + "@typescript-eslint/types": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "eslint-visitor-keys": "^3.3.0" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "@angular-eslint/eslint-plugin-template": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-16.0.1.tgz", + "integrity": "sha512-1hyfs+Iq7K2x3mDDE4985d8vDcMyknbE9HKHKUtRLfLKC9gnV3N5d4+UeySQ7Rrjvgzkc1g9qHADyuhwRWpDSA==", + "dev": true, + "requires": { + "@angular-eslint/bundled-angular-compiler": "16.0.1", + "@angular-eslint/utils": "16.0.1", + "@typescript-eslint/type-utils": "5.59.2", + "@typescript-eslint/utils": "5.59.2", + "aria-query": "5.1.3", + "axobject-query": "3.1.1" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" + } + }, + "@typescript-eslint/type-utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.2.tgz", + "integrity": "sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "5.59.2", + "@typescript-eslint/utils": "5.59.2", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/types": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "eslint-visitor-keys": "^3.3.0" + } + }, + "aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "requires": { + "deep-equal": "^2.0.5" + } + }, + "axobject-query": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", + "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", + "dev": true, + "requires": { + "deep-equal": "^2.0.5" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "@angular-eslint/schematics": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-1oJJEWVbgPkNK1E8rAJfrgxzNWWzJKv3frTHeAm8gvZ7GftYhHjDcrcnxLWrYNxb9+q8Awi0hvGta/4HROmmnA==", + "dev": true, + "requires": { + "@angular-eslint/eslint-plugin": "16.0.1", + "@angular-eslint/eslint-plugin-template": "16.0.1", + "@nx/devkit": "16.0.2", + "ignore": "5.2.4", + "nx": "16.0.2", + "strip-json-comments": "3.1.1", + "tmp": "0.2.1" + }, + "dependencies": { + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "requires": { + "rimraf": "^3.0.0" + } + } + } + }, + "@angular-eslint/template-parser": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-16.0.1.tgz", + "integrity": "sha512-x0+SwSeqa3TiVZan6fE5grHsCkjGqU+zAS2DB6wAw5pyvgNAIjrI4cZEQ8pkgHfXe5tuumTKztlkpisah5s/hg==", + "dev": true, + "requires": { + "@angular-eslint/bundled-angular-compiler": "16.0.1", + "eslint-scope": "^7.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "@angular-eslint/utils": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-16.0.1.tgz", + "integrity": "sha512-2xnJuhIrMZEYK6UyBym6FaFXZgopIIbqfQ4sAtMWY6zYkCEsVUvx5qKIrsnXAwvpDQrv0WiMXteqi/5ICpVMZQ==", + "dev": true, + "requires": { + "@angular-eslint/bundled-angular-compiler": "16.0.1", + "@typescript-eslint/utils": "5.59.2" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" + } + }, + "@typescript-eslint/types": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/utils": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.2", + "eslint-visitor-keys": "^3.3.0" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "@angular/animations": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-16.0.1.tgz", + "integrity": "sha512-ziRq1hGJJuQqQUHqNpEMp9uy1pVutvL8oNvawblh32u4bnLsVQU5gMd6sTonn0x4sphEwMNnuEmp/q6QRIx+pA==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/cdk": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.0.1.tgz", + "integrity": "sha512-GupYss6x84RWEoy3JTYu4Igr2SxHuV6whVKMScQG2/Gm+winOsOn7YWm0IZQuFnjSWIF2Va5B0Tp0IjFHWxTvA==", + "requires": { + "parse5": "^7.1.2", + "tslib": "^2.3.0" + } + }, + "@angular/cli": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.0.1.tgz", + "integrity": "sha512-0vIAcq/S+3NXXN4/gBQFVGaxLUQ0zhRxxHQQuiT7GGII73UySuhwvaFB1BEhYG5HVJjRrP1F0ZYbvsvrmFzfXQ==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.1600.1", + "@angular-devkit/core": "16.0.1", + "@angular-devkit/schematics": "16.0.1", + "@schematics/angular": "16.0.1", + "@yarnpkg/lockfile": "1.1.0", + "ansi-colors": "4.1.3", + "ini": "4.0.0", + "inquirer": "8.2.4", + "jsonc-parser": "3.2.0", + "npm-package-arg": "10.1.0", + "npm-pick-manifest": "8.0.1", + "open": "8.4.2", + "ora": "5.4.1", + "pacote": "15.1.3", + "resolve": "1.22.2", + "semver": "7.4.0", + "symbol-observable": "4.0.0", + "yargs": "17.7.2" + }, + "dependencies": { + "@angular-devkit/architect": { + "version": "0.1600.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1600.1.tgz", + "integrity": "sha512-7N3Dugrp3Fyyn3Q6RsxFNJJ2m1QuqcF3GHJcX7siINL37Hp6xI/q5gKffcd9rf20H1DYZE0VIbR1Sk31G6hMWg==", + "dev": true, + "requires": { + "@angular-devkit/core": "16.0.1", + "rxjs": "7.8.1" + } + } + } + }, + "@angular/common": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-16.0.1.tgz", + "integrity": "sha512-ic9Ri4Mepf4c0BTff7o4Oyl/a1vACNXXUzuoTwIjWnIqrH89dtwg7ncTD9Rv0N1lon7r4gXokTbn9A/Yk/0jbw==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/compiler": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-16.0.1.tgz", + "integrity": "sha512-7zNo6H1qVQow3T4EUul76SaIDSMRSl0hmtyWUzPjtWkxMjrCPSduqjA4/NHaG0KX1BsUvUtQEoDJ5jv/7EHWTQ==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/compiler-cli": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-16.0.1.tgz", + "integrity": "sha512-EW7Oxp8EuTz3vCNd4RAncZGB7dCUYviUkBA4PzuyPmL2copZPt12j9qx0pXXF3T6ydjoZ+99ZEgfkKOV6FeU3g==", + "requires": { + "@babel/core": "7.19.3", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^3.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.1.2", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^17.2.1" + }, + "dependencies": { + "@babel/core": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helpers": "^7.19.0", + "@babel/parser": "^7.19.3", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.3", + "@babel/types": "^7.19.3", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/generator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", + "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "requires": { + "@babel/types": "^7.21.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + } + } + }, + "@angular/core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-16.0.1.tgz", + "integrity": "sha512-3s4XBbzWgyWcjI0WFlNDKRxsbm4J+OKIL4mJCM9r8gWwno9y0K/giziAm9YMIJ4VOBIvrcMbOh85o44FCk8cRA==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/forms": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-16.0.1.tgz", + "integrity": "sha512-VbH/YnEBau0q97zI7BjSk0pu/i2S0Y/zmhvA2wgI2CCvtbqT6hCNdE/3rW6ZFBcnuCe+dFhuchXe6dX28epsvg==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/localize": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-16.0.1.tgz", + "integrity": "sha512-2zC7KE/JUA/JCHP+kEDSF8iZ9cyvd6OAPFE74yH8FjixQsaq9WhXiPtGkHC0bg9hWH858bRcCmA9BZr+zjntvA==", + "requires": { + "@babel/core": "7.19.3", + "glob": "8.1.0", + "yargs": "^17.2.1" + }, + "dependencies": { + "@babel/core": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helpers": "^7.19.0", + "@babel/parser": "^7.19.3", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.3", + "@babel/types": "^7.19.3", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/generator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", + "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "requires": { + "@babel/types": "^7.21.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@angular/platform-browser": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-16.0.1.tgz", + "integrity": "sha512-7XLIOnTnGDJLE4Q0zBz6eI9q5V3NnsTAJqIICJHc4gk6jNgVz90gtejAQ4EFbo0d83XGzwFL22hxID5Dj1WRIA==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/platform-browser-dynamic": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-16.0.1.tgz", + "integrity": "sha512-qrGlRPqJM42WZcHCbzwTA8SiK90xrhM/VrOL/8/1okuHn82gSWbbynpqycdZnsI9XMbW+HNhpKR2n8HKV38Jug==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/router": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-16.0.1.tgz", + "integrity": "sha512-4GH0SxPbuY08B/M0f3NEHf9yIFH+D3wlzWJHI75chfdqQ8gGAMG6B6PSmo3haicDxHcSnZTYNJXDLOQvaBAHcA==", + "requires": { + "tslib": "^2.3.0" + } + }, + "@ant-design/colors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.0.tgz", + "integrity": "sha512-iVm/9PfGCbC0dSMBrz7oiEXZaaGH7ceU40OJEfKmyuzR9R5CRimJYPlRiFtMQGQcbNMea/ePcoIebi4ASGYXtg==", + "requires": { + "@ctrl/tinycolor": "^3.4.0" + } + }, + "@ant-design/icons-angular": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-angular/-/icons-angular-16.0.0.tgz", + "integrity": "sha512-KWBmWZl2so49R/MdAT7aG+xaBlMKl9SArR3Du/iPA0Am9GI1i9R89KgnnLWz+gkzHTye15S1IBXpgts4GPPU/w==", + "requires": { + "@ant-design/colors": "^7.0.0", + "tslib": "^2.0.0" + } + }, + "@assemblyscript/loader": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.10.1.tgz", + "integrity": "sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg==", + "dev": true + }, + "@babel/code-frame": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", + "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "requires": { + "@babel/highlight": "^7.18.6" + } + }, + "@babel/compat-data": { + "version": "7.21.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.7.tgz", + "integrity": "sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==" + }, + "@babel/core": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz", + "integrity": "sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA==", + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.21.4", + "@babel/generator": "^7.21.4", + "@babel/helper-compilation-targets": "^7.21.4", + "@babel/helper-module-transforms": "^7.21.2", + "@babel/helpers": "^7.21.0", + "@babel/parser": "^7.21.4", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.4", + "@babel/types": "^7.21.4", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.2", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "@babel/generator": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.4.tgz", + "integrity": "sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==", + "requires": { + "@babel/types": "^7.21.4", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.21.5.tgz", + "integrity": "sha512-uNrjKztPLkUk7bpCNC0jEKDJzzkvel/W+HguzbN8krA+LPfC1CEobJEvAvGka2A/M+ViOqXdcRL0GqPUJSjx9g==", + "dev": true, + "requires": { + "@babel/types": "^7.21.5" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz", + "integrity": "sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==", + "requires": { + "@babel/compat-data": "^7.21.5", + "@babel/helper-validator-option": "^7.21.0", + "browserslist": "^4.21.3", + "lru-cache": "^5.1.1", + "semver": "^6.3.0" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "requires": { + "yallist": "^3.0.2" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + } + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.21.8", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.8.tgz", + "integrity": "sha512-+THiN8MqiH2AczyuZrnrKL6cAxFRRQDKW9h1YkBvbgKmAm6mwiacig1qT73DHIWMGo40GRnsEfN3LA+E6NtmSw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-member-expression-to-functions": "^7.21.5", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-replace-supers": "^7.21.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/helper-split-export-declaration": "^7.18.6", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.21.8", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.21.8.tgz", + "integrity": "sha512-zGuSdedkFtsFHGbexAvNuipg1hbtitDLo2XE8/uf6Y9sOQV1xsYX/2pNbtedp/X0eU1pIt+kGvaqHCowkRbS5g==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "regexpu-core": "^5.3.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", + "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-environment-visitor": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", + "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==" + }, + "@babel/helper-function-name": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", + "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "requires": { + "@babel/template": "^7.20.7", + "@babel/types": "^7.21.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", + "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.5.tgz", + "integrity": "sha512-nIcGfgwpH2u4n9GG1HpStW5Ogx7x7ekiFHbjjFRKXbn5zUvqO9ZgotCO4x1aNbKn/x/xOUaXEhyNHCwtFCpxWg==", + "dev": true, + "requires": { + "@babel/types": "^7.21.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz", + "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==", + "requires": { + "@babel/types": "^7.21.4" + } + }, + "@babel/helper-module-transforms": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz", + "integrity": "sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==", + "requires": { + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-module-imports": "^7.21.4", + "@babel/helper-simple-access": "^7.21.5", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/helper-validator-identifier": "^7.19.1", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.5", + "@babel/types": "^7.21.5" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz", + "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==", + "dev": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-wrap-function": "^7.18.9", + "@babel/types": "^7.18.9" + } + }, + "@babel/helper-replace-supers": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.21.5.tgz", + "integrity": "sha512-/y7vBgsr9Idu4M6MprbOVUfH3vs7tsIfnVWv/Ml2xgwvyH6LTngdfbf5AdsKwkJy4zgy1X/kuNrEKvhhK28Yrg==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-member-expression-to-functions": "^7.21.5", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.5", + "@babel/types": "^7.21.5" + } + }, + "@babel/helper-simple-access": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", + "integrity": "sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==", + "requires": { + "@babel/types": "^7.21.5" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz", + "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==", + "dev": true, + "requires": { + "@babel/types": "^7.20.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", + "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-string-parser": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", + "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==" + }, + "@babel/helper-validator-identifier": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", + "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==" + }, + "@babel/helper-validator-option": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz", + "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==" + }, + "@babel/helper-wrap-function": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz", + "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.19.0", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.20.5", + "@babel/types": "^7.20.5" + } + }, + "@babel/helpers": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.5.tgz", + "integrity": "sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==", + "requires": { + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.21.5", + "@babel/types": "^7.21.5" + } + }, + "@babel/highlight": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", + "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "requires": { + "@babel/helper-validator-identifier": "^7.18.6", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.21.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.8.tgz", + "integrity": "sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==" + }, + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", + "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz", + "integrity": "sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-proposal-optional-chaining": "^7.20.7" + } + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", + "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-proposal-class-static-block": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.21.0.tgz", + "integrity": "sha512-XP5G9MWNUskFuP30IfFSEFB0Z6HzLIUcjYM4bYOPHXl7eiJ9HFv8tWj6TXTN5QODiEhDZAeI4hLok2iHFFV4hw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", + "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", + "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz", + "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", + "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", + "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0.tgz", + "integrity": "sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-import-assertions": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz", + "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.19.0" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.21.5.tgz", + "integrity": "sha512-wb1mhwGOCaXHDTcsRYMKF9e5bbMgqwxtqa2Y1ifH96dXJPwbuLX9qHy3clhrxVqgMz7nyNXs8VkxdH8UBcjKqA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.21.5" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz", + "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.21.0.tgz", + "integrity": "sha512-Mdrbunoh9SxwFZapeHVrwFmri16+oYotcZysSzhNIVDwIAb1UV+kvnxULSYq9J3/q5MDG+4X6w8QVgD1zhBXNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.21.0.tgz", + "integrity": "sha512-RZhbYTCEUAe6ntPehC4hlslPWosNHDox+vAs4On/mCLRLfoDVHf6hVEd7kuxr1RnHwJmxFfUM3cZiZRmPxJPXQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-replace-supers": "^7.20.7", + "@babel/helper-split-export-declaration": "^7.18.6", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.21.5.tgz", + "integrity": "sha512-TR653Ki3pAwxBxUe8srfF3e4Pe3FTA46uaNHYyQwIoM4oWKSoOZiDNyHJ0oIoDIUPSRQbQG7jzgVBX3FPVne1Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/template": "^7.20.7" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.21.3.tgz", + "integrity": "sha512-bp6hwMFzuiE4HqYEyoGJ/V2LeIWn+hLVKc4pnj++E5XQptwhtcGmSayM029d/j2X1bPKGTlsyPwAubuU22KhMA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.5.tgz", + "integrity": "sha512-nYWpjKW/7j/I/mZkGVgHJXh4bA1sfdFnJoOXwJuj4m3Q2EraO/8ZyrkCau9P5tbHQk01RMSt6KYLCsW7730SXQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.21.5" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-function-name": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz", + "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.5.tgz", + "integrity": "sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.21.5", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-simple-access": "^7.21.5" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz", + "integrity": "sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-validator-identifier": "^7.19.1" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz", + "integrity": "sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.20.5", + "@babel/helper-plugin-utils": "^7.20.2" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", + "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.6" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.21.3.tgz", + "integrity": "sha512-Wxc+TvppQG9xWFYatvCGPvZ6+SIUxQ2ZdiBP+PHYMIjnPXD+uThCshaz4NZOnODAtBjjcVQQ/3OKs9LW28purQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz", + "integrity": "sha512-ZoYBKDb6LyMi5yCsByQ5jmXsHAQDDYeexT1Szvlmui+lADvfSecr5Dxd/PkrTC3pAD182Fcju1VQkB4oCp9M+w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.21.5", + "regenerator-transform": "^0.15.1" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.21.4.tgz", + "integrity": "sha512-1J4dhrw1h1PqnNNpzwxQ2UBymJUF8KuPjAAnlLwZcGhHAIqUigFW7cdK6GHoB64ubY4qXQNYknoUeks4Wz7CUA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.21.4", + "@babel/helper-plugin-utils": "^7.20.2", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz", + "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.21.5.tgz", + "integrity": "sha512-LYm/gTOwZqsYohlvFUe/8Tujz75LqqVC2w+2qPHLR+WyWHGCZPN1KBpJCJn+4Bk4gOkQy/IXKIge6az5MqwlOg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.21.5" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/preset-env": { + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.21.4.tgz", + "integrity": "sha512-2W57zHs2yDLm6GD5ZpvNn71lZ0B/iypSdIeq25OurDKji6AdzV07qp4s3n1/x5BqtiGaTrPN3nerlSCaC5qNTw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.21.4", + "@babel/helper-compilation-targets": "^7.21.4", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-validator-option": "^7.21.0", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.20.7", + "@babel/plugin-proposal-async-generator-functions": "^7.20.7", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-class-static-block": "^7.21.0", + "@babel/plugin-proposal-dynamic-import": "^7.18.6", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@babel/plugin-proposal-json-strings": "^7.18.6", + "@babel/plugin-proposal-logical-assignment-operators": "^7.20.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", + "@babel/plugin-proposal-numeric-separator": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/plugin-proposal-optional-catch-binding": "^7.18.6", + "@babel/plugin-proposal-optional-chaining": "^7.21.0", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-proposal-private-property-in-object": "^7.21.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.18.6", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.20.0", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.20.7", + "@babel/plugin-transform-async-to-generator": "^7.20.7", + "@babel/plugin-transform-block-scoped-functions": "^7.18.6", + "@babel/plugin-transform-block-scoping": "^7.21.0", + "@babel/plugin-transform-classes": "^7.21.0", + "@babel/plugin-transform-computed-properties": "^7.20.7", + "@babel/plugin-transform-destructuring": "^7.21.3", + "@babel/plugin-transform-dotall-regex": "^7.18.6", + "@babel/plugin-transform-duplicate-keys": "^7.18.9", + "@babel/plugin-transform-exponentiation-operator": "^7.18.6", + "@babel/plugin-transform-for-of": "^7.21.0", + "@babel/plugin-transform-function-name": "^7.18.9", + "@babel/plugin-transform-literals": "^7.18.9", + "@babel/plugin-transform-member-expression-literals": "^7.18.6", + "@babel/plugin-transform-modules-amd": "^7.20.11", + "@babel/plugin-transform-modules-commonjs": "^7.21.2", + "@babel/plugin-transform-modules-systemjs": "^7.20.11", + "@babel/plugin-transform-modules-umd": "^7.18.6", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.20.5", + "@babel/plugin-transform-new-target": "^7.18.6", + "@babel/plugin-transform-object-super": "^7.18.6", + "@babel/plugin-transform-parameters": "^7.21.3", + "@babel/plugin-transform-property-literals": "^7.18.6", + "@babel/plugin-transform-regenerator": "^7.20.5", + "@babel/plugin-transform-reserved-words": "^7.18.6", + "@babel/plugin-transform-shorthand-properties": "^7.18.6", + "@babel/plugin-transform-spread": "^7.20.7", + "@babel/plugin-transform-sticky-regex": "^7.18.6", + "@babel/plugin-transform-template-literals": "^7.18.9", + "@babel/plugin-transform-typeof-symbol": "^7.18.9", + "@babel/plugin-transform-unicode-escapes": "^7.18.10", + "@babel/plugin-transform-unicode-regex": "^7.18.6", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.21.4", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "core-js-compat": "^3.25.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "@babel/runtime": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", + "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "@babel/template": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", + "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", + "requires": { + "@babel/code-frame": "^7.18.6", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7" + } + }, + "@babel/traverse": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", + "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", + "requires": { + "@babel/code-frame": "^7.21.4", + "@babel/generator": "^7.21.5", + "@babel/helper-environment-visitor": "^7.21.5", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/parser": "^7.21.5", + "@babel/types": "^7.21.5", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "@babel/generator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", + "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "requires": { + "@babel/types": "^7.21.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + } + } + }, + "@babel/types": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz", + "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==", + "requires": { + "@babel/helper-string-parser": "^7.21.5", + "@babel/helper-validator-identifier": "^7.19.1", + "to-fast-properties": "^2.0.0" + } + }, + "@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "optional": true + }, + "@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, + "@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==" + }, + "@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true + }, + "@esbuild/android-arm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", + "integrity": "sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz", + "integrity": "sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.18.tgz", + "integrity": "sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz", + "integrity": "sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz", + "integrity": "sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz", + "integrity": "sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz", + "integrity": "sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz", + "integrity": "sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz", + "integrity": "sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz", + "integrity": "sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz", + "integrity": "sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz", + "integrity": "sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz", + "integrity": "sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz", + "integrity": "sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz", + "integrity": "sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz", + "integrity": "sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz", + "integrity": "sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz", + "integrity": "sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz", + "integrity": "sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz", + "integrity": "sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz", + "integrity": "sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz", + "integrity": "sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==", + "dev": true, + "optional": true + }, + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + } + }, + "@eslint-community/regexpp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true + }, + "@eslint/eslintrc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.5.2", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + } + } + }, + "@eslint/js": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "dev": true + }, + "@fortawesome/angular-fontawesome": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.13.0.tgz", + "integrity": "sha512-gzSPRdveOXNO7NIiMgTyB46aiHG0i98KinnAEqHXi8qzraM/kCcHn/0y3f4MhemX6kftwsFli0IU8RyHmtXlSQ==", + "requires": { + "tslib": "^2.4.1" + } + }, + "@fortawesome/fontawesome-common-types": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz", + "integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==" + }, + "@fortawesome/fontawesome-svg-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz", + "integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.4.0" + } + }, + "@fortawesome/free-brands-svg-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz", + "integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.4.0" + } + }, + "@fortawesome/free-regular-svg-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz", + "integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.4.0" + } + }, + "@fortawesome/free-solid-svg-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz", + "integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.4.0" + } + }, + "@fullhuman/postcss-purgecss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-5.0.0.tgz", + "integrity": "sha512-onDS/b/2pMRzqSoj4qOs2tYFmOpaspjTAgvACIHMPiicu1ptajiBruTrjBzTKdxWdX0ldaBb7wj8nEaTLyFkJw==", + "dev": true, + "requires": { + "purgecss": "^5.0.0" + } + }, + "@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true + }, + "@humanwhocodes/config-array": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "requires": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true + }, + "ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true + }, + "emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + } + }, + "wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "requires": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + } + } + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" + }, + "@jridgewell/source-map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz", + "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, + "@jridgewell/trace-mapping": { + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "requires": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, + "@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true + }, + "@ngtools/webpack": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.0.1.tgz", + "integrity": "sha512-CZHFPMiJuOe241kO1VSSPOQ5Z9hWWkY7eSs3hnS50Ntgd4YzlHAydqexmEFpXD2YLOFjdbNETCyJ2BQTM4Kwtw==", + "dev": true, + "requires": {} + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@npmcli/fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", + "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "dev": true, + "requires": { + "semver": "^7.3.5" + } + }, + "@npmcli/git": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-4.0.4.tgz", + "integrity": "sha512-5yZghx+u5M47LghaybLCkdSyFzV/w4OuH12d96HO389Ik9CDsLaDZJVynSGGVJOLn6gy/k7Dz5XYcplM3uxXRg==", + "dev": true, + "requires": { + "@npmcli/promise-spawn": "^6.0.0", + "lru-cache": "^7.4.4", + "npm-pick-manifest": "^8.0.0", + "proc-log": "^3.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^3.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "@npmcli/installed-package-contents": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", + "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "dev": true, + "requires": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + } + }, + "@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "dev": true, + "requires": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "@npmcli/node-gyp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", + "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "dev": true + }, + "@npmcli/promise-spawn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-6.0.2.tgz", + "integrity": "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==", + "dev": true, + "requires": { + "which": "^3.0.0" + }, + "dependencies": { + "which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "@npmcli/run-script": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-6.0.2.tgz", + "integrity": "sha512-NCcr1uQo1k5U+SYlnIrbAh3cxy+OQT1VtqiAbxdymSlptbzBb62AjH2xXgjNCoP073hoa1CfCAcwoZ8k96C4nA==", + "dev": true, + "requires": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/promise-spawn": "^6.0.0", + "node-gyp": "^9.0.0", + "read-package-json-fast": "^3.0.0", + "which": "^3.0.0" + }, + "dependencies": { + "which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "@nrwl/devkit": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-16.0.2.tgz", + "integrity": "sha512-SAEcImeQHdSTauO05FUn2vVl9/y5Kx1LNCZ4YE+SdY5/QRq18fuo/DCWmjOGG9M8r06vYGsAgMzkiB4soimcyA==", + "dev": true, + "requires": { + "@nx/devkit": "16.0.2" + } + }, + "@nrwl/tao": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-16.0.2.tgz", + "integrity": "sha512-wimEe4OTpI7/nDK67RnpZpEXCU+fzA0sDgpIhMgbpPd0vPmKgaZv4nbs8zrm0goFlacmmnLaGRhhGYMOxE+1Lg==", + "dev": true, + "requires": { + "nx": "16.0.2" + } + }, + "@nx/devkit": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-16.0.2.tgz", + "integrity": "sha512-BY1Bj0BbAl6XJL0O+QGTWPs/3WMJTEQ+Y4Lfoq4dZM7RllE6rAylr54NA2wa4lsgordZhq1+0g5PVhKKvSVRRw==", + "dev": true, + "requires": { + "@nrwl/devkit": "16.0.2", + "ejs": "^3.1.7", + "ignore": "^5.0.4", + "semver": "7.3.4", + "tmp": "~0.2.1", + "tslib": "^2.3.0" + }, + "dependencies": { + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "requires": { + "rimraf": "^3.0.0" + } + } + } + }, + "@nx/nx-darwin-arm64": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-16.0.2.tgz", + "integrity": "sha512-nAT8WJ/qKGEvUcoFLHHye1dbwCd7b8CTZJlDF+ZkyCD/UZRHt4eJxy8gvKmxgkZTFb2+PPMQt4UORCUGpZzuoA==", + "dev": true, + "optional": true + }, + "@nx/nx-darwin-x64": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-16.0.2.tgz", + "integrity": "sha512-r0rfOrZaOyrwFR5a0UT05xkYRumfkP65cRSZM1TjCA027AG9llYtkLT1hlz8uMKt+P12zrWVzXSqGLDi022ZZg==", + "dev": true, + "optional": true + }, + "@nx/nx-linux-arm-gnueabihf": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-16.0.2.tgz", + "integrity": "sha512-TfDQaGvCIDjn9sPg5U1Fr2rsSul/4PIQB59qrLBJRPiCWgpzwO71Il1qwSX68En+JH3lwXr+g5EjcDIEQ8fGYA==", + "dev": true, + "optional": true + }, + "@nx/nx-linux-arm64-gnu": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-16.0.2.tgz", + "integrity": "sha512-MICaUp7uz8WVQFXWPrmQaX1o4bdL7f3C7b3MDDf6+Zau6RcyQuw97UEKaYi9OqrV3w8yuPplqoLosFblAgb8uw==", + "dev": true, + "optional": true + }, + "@nx/nx-linux-arm64-musl": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-16.0.2.tgz", + "integrity": "sha512-wcBURG+6A2srm+6ujj8SShjwmYWs0eHI5D8vgZr8Bni+lXbKP/IosE9JGXKtRoh27/owyR8PGHhDVzjv46tlFg==", + "dev": true, + "optional": true + }, + "@nx/nx-linux-x64-gnu": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-16.0.2.tgz", + "integrity": "sha512-Xyml2gFdVDHUj2g67DKz2aD78x1BciN1ZaaBTCxXL4MHfwR78SZa7mtRtE+1kj5OgVIwupZP50jq7C8GuSn3Hw==", + "dev": true, + "optional": true + }, + "@nx/nx-linux-x64-musl": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-16.0.2.tgz", + "integrity": "sha512-j3xdN8I5DlTgW5N5eCquyBZswrrYf6EazUCvnEpeejygwh3N6XN7DlD68Bs0CB4Zmd0tWLfTjNVAtUJSP6g2mA==", + "dev": true, + "optional": true + }, + "@nx/nx-win32-arm64-msvc": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-16.0.2.tgz", + "integrity": "sha512-R2pzoW3SUFBbe9C1vifJnXuysPl6kmutQHN2yQ9lwJptzPvMxfDU1FuXmKCGRUGmEwFxk/XPhwDL/ZcbABTrzw==", + "dev": true, + "optional": true + }, + "@nx/nx-win32-x64-msvc": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-16.0.2.tgz", + "integrity": "sha512-r4H/SsqfpIJa8QLSpnscgkMnLsnkRYXj8TcILDrf+nJazfEdJZLUvVhN9O85OB7pskv86NuGfnJmJHHXy6QVQg==", + "dev": true, + "optional": true + }, + "@parcel/watcher": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.0.4.tgz", + "integrity": "sha512-cTDi+FUDBIUOBKEtj+nhiJ71AZVlkAsQFuGQTun5tV9mwQBQgZvhCzG+URPQc8myeN32yRVZEfVAPCs1RW+Jvg==", + "dev": true, + "requires": { + "node-addon-api": "^3.2.1", + "node-gyp-build": "^4.3.0" + } + }, + "@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true + }, + "@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, + "@rollup/plugin-json": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.0.0.tgz", + "integrity": "sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^5.0.1" + } + }, + "@rollup/plugin-node-resolve": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.2.tgz", + "integrity": "sha512-Y35fRGUjC3FaurG722uhUuG8YHOJRJQbI6/CkbRkdPotSpDj9NtIN85z1zrcyDcCQIW4qp5mgG72U+gJ0TAFEg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-builtin-module": "^3.2.1", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + } + }, + "@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + } + }, + "@schematics/angular": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-16.0.1.tgz", + "integrity": "sha512-MNgH/iB3WWxMLFVHJjtXCHZ8YHtfx2e3mX2Ds5P43OTgSnTk6tHabqvwxJ4wzjoyoPUyXWLhHt0diCmVtDTNeQ==", + "dev": true, + "requires": { + "@angular-devkit/core": "16.0.1", + "@angular-devkit/schematics": "16.0.1", + "jsonc-parser": "3.2.0" + } + }, + "@sigstore/protobuf-specs": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.1.0.tgz", + "integrity": "sha512-a31EnjuIDSX8IXBUib3cYLDRlPMU36AWX4xS8ysLaNu4ZzUesDiPt83pgrW2X1YLMe5L2HbDyaKK5BrL4cNKaQ==", + "dev": true + }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true, + "optional": true, + "peer": true + }, + "@tauri-apps/api": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-beta.3.tgz", + "integrity": "sha512-gDSJzKpBs6efXw2ZWqjl9QVNImY5GR5qygXqB7JK4y7prcQInxnTj2ARFR0vD4wuzkrUHGrlIKraiJJPHWJ9vg==" + }, + "@tauri-apps/plugin-cli": { + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-cli/-/plugin-cli-2.0.0-beta.1.tgz", + "integrity": "sha512-8VB0RTFi6SrCZvWDiOW+DVhCo7IsBenWfTIF6f8YAU+TnLSOAxpVc2MOM5PimVdKU2hu+mlpjSmPhd9RSCRfAw==", + "requires": { + "@tauri-apps/api": "2.0.0-beta.2" + }, + "dependencies": { + "@tauri-apps/api": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-beta.2.tgz", + "integrity": "sha512-4r1r6kgttzIWxJ3HxkZQH+b7EiUtKhdUCPbi0KSalD+2T3j6klw+v8VyxhKwEdjM/eo60NE+J33v1E/Urq8puw==" + } + } + }, + "@tauri-apps/plugin-clipboard-manager": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.0.0-alpha.4.tgz", + "integrity": "sha512-/xPQBXuzD8cSh81xkTphIAKxSD2kGsv8deKK+Qoh+89puay1xJjjnxVv5b9IKKn0G8r8HPm+JDEamlKxQbOgnA==", + "requires": { + "@tauri-apps/api": "2.0.0-alpha.12" + }, + "dependencies": { + "@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==" + } + } + }, + "@tauri-apps/plugin-dialog": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-alpha.4.tgz", + "integrity": "sha512-4NxBgDzxrZ8hPE9OMRYwsXYN2BxQYI/5l1UKEI5V4srFTZK81Vj5GGksCf7gQREZg7CmBRCk95qYx338A6oCag==", + "requires": { + "@tauri-apps/api": "2.0.0-alpha.12" + }, + "dependencies": { + "@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==" + } + } + }, + "@tauri-apps/plugin-notification": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0-alpha.4.tgz", + "integrity": "sha512-mXUuZoZEEMAedGNJxPZPLET3vY4lSmHCpfrfZIytJRU6eSxbec90L3fB4YqvW9+yqkplyXkvpiThILbT5A4Q4w==", + "requires": { + "@tauri-apps/api": "2.0.0-alpha.12" + }, + "dependencies": { + "@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==" + } + } + }, + "@tauri-apps/plugin-os": { + "version": "2.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0-alpha.5.tgz", + "integrity": "sha512-dedPdad+ykMSZz2KUfrhUDyy32G2WH5aLkYdcACF58KC6GBvKuyR5sQ1ZE/pddo2L6VRhyujLp8zJEfRN3AUcQ==", + "requires": { + "@tauri-apps/api": "2.0.0-alpha.12" + }, + "dependencies": { + "@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==" + } + } + }, + "@tauri-apps/plugin-shell": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0-alpha.4.tgz", + "integrity": "sha512-Go/+EwGVuAXbSg2l2M5E2gT6cir66KV4CXC9P4gPHeead8Ar/B9wQvuINzcrYzL/HCcL7fFfKlqqu/XPTN2qvQ==", + "requires": { + "@tauri-apps/api": "2.0.0-alpha.12" + }, + "dependencies": { + "@tauri-apps/api": { + "version": "2.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-alpha.12.tgz", + "integrity": "sha512-acpNZQxFgHMHC5qV/IUg4IL/xmypzfxHB4ECkwb58fT48H4zBmklNd5TC0k7BvLUBoSmmgHc4InbYwQai392Yw==" + } + } + }, + "@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true + }, + "@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true + }, + "@tufjs/canonical-json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz", + "integrity": "sha512-QTnf++uxunWvG2z3UFNzAoQPHxnSXOwtaI3iJ+AohhV+5vONuArPjJE7aPXPVXfXJsqrVbZBu9b81AJoSd09IQ==", + "dev": true + }, + "@tufjs/models": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-1.0.4.tgz", + "integrity": "sha512-qaGV9ltJP0EO25YfFUPhxRVK0evXFIAGicsVXuRim4Ed9cjPxYhNnNJ49SFmbeLgtxpslIkX317IgpfcHPVj/A==", + "dev": true, + "requires": { + "@tufjs/canonical-json": "1.0.0", + "minimatch": "^9.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/chrome": { + "version": "0.0.236", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.236.tgz", + "integrity": "sha512-ArQoxO9WtDY6GWcT2cpo+D+hyASPeFt7PHQEUDXwQhRS00Rbop07rnEOA046yws0HkM83Tcew/hW6Dgvnj4iMQ==", + "dev": true, + "requires": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/connect-history-api-fallback": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", + "integrity": "sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true, + "optional": true, + "peer": true + }, + "@types/cors": { + "version": "2.8.13", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz", + "integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@types/node": "*" + } + }, + "@types/d3": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz", + "integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==", + "dev": true, + "requires": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "@types/d3-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.2.tgz", + "integrity": "sha512-5mjGjz6XOXKOCdTajXTZ/pMsg236RdiwKPrRPWAEf/2S/+PzwY+LLYShUpeysWaMvsdS7LArh6GdUefoxpchsQ==", + "dev": true + }, + "@types/d3-axis": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.1.tgz", + "integrity": "sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-brush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.1.tgz", + "integrity": "sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==", + "dev": true + }, + "@types/d3-color": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.0.2.tgz", + "integrity": "sha512-WVx6zBiz4sWlboCy7TCgjeyHpNjMsoF36yaagny1uXfbadc9f+5BeBf7U+lRmQqY3EHbGQpP8UdW8AC+cywSwQ==", + "dev": true + }, + "@types/d3-contour": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.1.tgz", + "integrity": "sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==", + "dev": true, + "requires": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "@types/d3-delaunay": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.0.tgz", + "integrity": "sha512-iGm7ZaGLq11RK3e69VeMM6Oqj2SjKUB9Qhcyd1zIcqn2uE8w9GFB445yCY46NOQO3ByaNyktX1DK+Etz7ZaX+w==", + "dev": true + }, + "@types/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==", + "dev": true + }, + "@types/d3-drag": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", + "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-dsv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz", + "integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==", + "dev": true + }, + "@types/d3-ease": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz", + "integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==", + "dev": true + }, + "@types/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==", + "dev": true, + "requires": { + "@types/d3-dsv": "*" + } + }, + "@types/d3-force": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.3.tgz", + "integrity": "sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==", + "dev": true + }, + "@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "dev": true + }, + "@types/d3-geo": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.2.tgz", + "integrity": "sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/d3-hierarchy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.0.2.tgz", + "integrity": "sha512-+krnrWOZ+aQB6v+E+jEkmkAx9HvsNAD+1LCD0vlBY3t+HwjKnsBFbpVLx6WWzDzCIuiTWdAxXMEnGnVXpB09qQ==", + "dev": true + }, + "@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "dev": true, + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", + "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==", + "dev": true + }, + "@types/d3-polygon": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz", + "integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==", + "dev": true + }, + "@types/d3-quadtree": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz", + "integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==", + "dev": true + }, + "@types/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==", + "dev": true + }, + "@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "dev": true, + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==", + "dev": true + }, + "@types/d3-selection": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.2.tgz", + "integrity": "sha512-d29EDd0iUBrRoKhPndhDY6U/PYxOWqgIZwKTooy2UkBfU7TNZNpRho0yLWPxlatQrFWk2mnTu71IZQ4+LRgKlQ==", + "dev": true + }, + "@types/d3-shape": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.0.2.tgz", + "integrity": "sha512-5+ButCmIfNX8id5seZ7jKj3igdcxx+S9IDBiT35fQGTLZUfkFgTv+oBH34xgeoWDKpWcMITSzBILWQtBoN5Piw==", + "dev": true, + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "dev": true + }, + "@types/d3-time-format": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz", + "integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==", + "dev": true + }, + "@types/d3-timer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz", + "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==", + "dev": true + }, + "@types/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-Sv4qEI9uq3bnZwlOANvYK853zvpdKEm1yz9rcc8ZTsxvRklcs9Fx4YFuGA3gXoQN/c/1T6QkVNjhaRO/cWj94g==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-zoom": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz", + "integrity": "sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==", + "dev": true, + "requires": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "@types/data-urls": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/data-urls/-/data-urls-3.0.4.tgz", + "integrity": "sha512-XRY2WVaOFSTKpNMaplqY1unPgAGk/DosOJ+eFrB6LJcFFbRH3nVbwJuGqLmDwdTWWx+V7U614/kmrj1JmCDl2A==", + "dev": true, + "requires": { + "@types/whatwg-mimetype": "*", + "@types/whatwg-url": "*" + } + }, + "@types/eslint": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.2.2.tgz", + "integrity": "sha512-nQxgB8/Sg+QKhnV8e0WzPpxjIGT3tuJDDzybkDi8ItE/IgTlHo07U0shaIjzhcvQxlq9SDRE42lsJ23uvEgJ2A==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true + }, + "@types/express": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", + "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.35", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", + "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "@types/filesystem": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.32.tgz", + "integrity": "sha512-Yuf4jR5YYMR2DVgwuCiP11s0xuVRyPKmz8vo6HBY3CGdeMj8af93CFZX+T82+VD1+UqHOxTq31lO7MI7lepBtQ==", + "dev": true, + "requires": { + "@types/filewriter": "*" + } + }, + "@types/filewriter": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.29.tgz", + "integrity": "sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==", + "dev": true + }, + "@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", + "dev": true + }, + "@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "requires": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/har-format": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.8.tgz", + "integrity": "sha512-OP6L9VuZNdskgNN3zFQQ54ceYD8OLq5IbqO4VK91ORLfOm7WdT/CiT/pHEBSQEqCInJ2y3O6iCm/zGtPElpgJQ==", + "dev": true + }, + "@types/http-proxy": { + "version": "1.17.11", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", + "integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/jasmine": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.3.1.tgz", + "integrity": "sha512-Vu8l+UGcshYmV1VWwULgnV/2RDbBaO6i2Ptx7nd//oJPIZGhoI1YLST4VKagD2Pq/Bc2/7zvtvhM7F3p4SN7kQ==", + "dev": true + }, + "@types/jasminewd2": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/jasminewd2/-/jasminewd2-2.0.10.tgz", + "integrity": "sha512-J7mDz7ovjwjc+Y9rR9rY53hFWKATcIkrr9DwQWmOas4/pnIPJTXawnzjwpHm3RSxz/e3ZVUvQ7cRbd5UQLo10g==", + "dev": true, + "requires": { + "@types/jasmine": "*" + } + }, + "@types/json-schema": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", + "dev": true + }, + "@types/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-zK4gSFMjgslsv5Lyvr3O1yCjgmnE4pr8jbG8qVn4QglMwtpvPCf4YT2Wma7Nk95OxUUJI8Z+kzdXohbM7mVpGw==", + "peer": true + }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, + "@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true + }, + "@types/node": { + "version": "20.1.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.5.tgz", + "integrity": "sha512-IvGD1CD/nego63ySR7vrAKEX3AJTcmrAN2kn+/sDNLi1Ff5kBzDeEdqWDplK+0HAEoLYej137Sk0cUU8OLOlMg==", + "dev": true + }, + "@types/psl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/psl/-/psl-1.1.0.tgz", + "integrity": "sha512-HhZnoLAvI2koev3czVPzBNRYvdrzJGLjQbWZhqFmS9Q6a0yumc5qtfSahBGb5g+6qWvA8iiQktqGkwoIXa/BNQ==", + "dev": true + }, + "@types/q": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz", + "integrity": "sha1-vShOV8hPEyXacCur/IKlMoGQwMU=", + "dev": true + }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, + "@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "@types/selenium-webdriver": { + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-3.0.19.tgz", + "integrity": "sha512-OFUilxQg+rWL2FMxtmIgCkUDlJB6pskkpvmew7yeXfzzsOBb5rc+y2+DjHm+r3r1ZPPcJefK3DveNSYWGiy68g==", + "dev": true + }, + "@types/semver": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", + "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", + "dev": true + }, + "@types/send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", + "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/serve-static": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz", + "integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==", + "dev": true, + "requires": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/source-list-map": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", + "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", + "dev": true + }, + "@types/topojson-client": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.1.tgz", + "integrity": "sha512-E4/Z2Xg56kVLRzYWem/6uOKVcVNqqxEqlWM9qCG2tCV1BxuzvvXC02/ELoGJWgtKkQhfycBPlMFEuTFdA/YiTg==", + "dev": true, + "requires": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "@types/topojson-simplify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/topojson-simplify/-/topojson-simplify-3.0.1.tgz", + "integrity": "sha512-H7SS2X11Lo3iRT3e7R6jPTAazOoSLD0LKIGq1b+4m/76Md46JfeU3zVIhxfIX9FY7oiyEbXwGumjK1GUXwIIMA==", + "dev": true, + "requires": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "@types/topojson-specification": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.2.tgz", + "integrity": "sha512-SGc1NdX9g3UGDp6S+p+uyG+Z8CehS51sUJ9bejA25Xgn2kkAguILk6J9nxXK+0M/mbTBN7ypMA7+4HVLNMJ8ag==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/webextension-polyfill": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.8.3.tgz", + "integrity": "sha512-GN+Hjzy9mXjWoXKmaicTegv3FJ0WFZ3aYz77Wk8TMp1IY3vEzvzj1vnsa0ggV7vMI1i+PUxe4qqnIJKCzf9aTg==", + "dev": true + }, + "@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true + }, + "@types/webpack": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.0.tgz", + "integrity": "sha512-8cP0CzcxUiFuA9xGJkfeVpqmWTk9nx6CWwamRGCj95ph1SmlRRk9KlCZ6avhCbZd4L68LvYT6l1kpdEnQXrF8w==", + "dev": true, + "requires": { + "@types/node": "*", + "tapable": "^2.2.0", + "webpack": "^5" + } + }, + "@types/webpack-sources": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz", + "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/source-list-map": "*", + "source-map": "^0.7.3" + } + }, + "@types/whatwg-encoding": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/whatwg-encoding/-/whatwg-encoding-2.0.3.tgz", + "integrity": "sha512-7TJfeaSFIWAKQ4ZynOb5zV3xzJQEEmL0U0j+uH7tnqfL97apXDTwMo0dB2uAWXAbr2dRRi5/eO9jV9dK/1GkiA==", + "dev": true + }, + "@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, + "@types/whatwg-url": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.4.tgz", + "integrity": "sha512-lXCmTWSHJvf0TRSO58nm978b8HJ/EdsSsEKLd3ODHFjo+3VGAyyTp4v50nWvwtzBxSMQrVOK7tcuN0zGPLICMw==", + "dev": true, + "requires": { + "@types/webidl-conversions": "*" + } + }, + "@types/ws": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@typescript-eslint/eslint-plugin": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", + "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/type-utils": "5.59.6", + "@typescript-eslint/utils": "5.59.6", + "debug": "^4.3.4", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "@typescript-eslint/parser": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", + "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", + "debug": "^4.3.4" + } + }, + "@typescript-eslint/scope-manager": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz", + "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6" + } + }, + "@typescript-eslint/type-utils": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz", + "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "5.59.6", + "@typescript-eslint/utils": "5.59.6", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "@typescript-eslint/types": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz", + "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz", + "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "dependencies": { + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "@typescript-eslint/utils": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz", + "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz", + "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.59.6", + "eslint-visitor-keys": "^3.3.0" + } + }, + "@vitejs/plugin-basic-ssl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.1.tgz", + "integrity": "sha512-pcub+YbFtFhaGRTo1832FQHQSHvMrlb43974e2eS8EKleR3p1cDdkJFPci1UhwkEf1J9Bz+wKBSzqpKp7nNj2A==", + "dev": true, + "requires": {} + }, + "@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, + "@yarnpkg/parsers": { + "version": "3.0.0-rc.43", + "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.0-rc.43.tgz", + "integrity": "sha512-AhFF3mIDfA+jEwQv2WMHmiYhOvmdbh2qhUkDVQfiqzQtUwS4BgoWwom5NpSPg4Ix5vOul+w1690Bt21CkVLpgg==", + "dev": true, + "requires": { + "js-yaml": "^3.10.0", + "tslib": "^2.4.0" + } + }, + "@zkochan/js-yaml": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz", + "integrity": "sha512-nzvgl3VfhcELQ8LyVrYOru+UtAy1nrygk2+AGbTm8a5YcO6o8lSjAT+pfg3vJWxIoZKOUhrK6UU7xW/+00kQrg==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + } + } + }, + "abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "peer": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, + "adm-zip": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz", + "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==", + "dev": true + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "agentkeepalive": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.3.0.tgz", + "integrity": "sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "depd": "^2.0.0", + "humanize-ms": "^1.2.1" + } + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true + }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "requires": { + "type-fest": "^0.21.3" + } + }, + "ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true + }, + "archiver": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.1.tgz", + "integrity": "sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w==", + "dev": true, + "requires": { + "archiver-utils": "^2.1.0", + "async": "^3.2.3", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.0.0", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + } + }, + "archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "requires": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + } + } + }, + "array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + } + }, + "array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + } + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "autoprefixer": { + "version": "10.4.14", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", + "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", + "requires": { + "browserslist": "^4.21.5", + "caniuse-lite": "^1.0.30001464", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "requires": { + "possible-typed-array-names": "^1.0.0" + } + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", + "dev": true + }, + "axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dev": true, + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, + "babel-loader": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.2.tgz", + "integrity": "sha512-mN14niXW43tddohGl8HPu5yfQq70iUThvFL/4QzESA7GcZoC0eVOhvWdQ8+3UlSjaDE9MVtsW9mxDY07W7VpVA==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.2", + "schema-utils": "^4.0.0" + } + }, + "babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + } + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", + "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.17.7", + "@babel/helper-define-polyfill-provider": "^0.3.3", + "semver": "^6.1.1" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", + "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.3", + "core-js-compat": "^3.25.1" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", + "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.3" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "optional": true, + "peer": true + }, + "batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "blocking-proxy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/blocking-proxy/-/blocking-proxy-1.0.1.tgz", + "integrity": "sha512-KE8NFMZr3mN2E0HcvCgRtX7DjhiIQrwle+nSVJVC/yqFb9+xznHl2ZcoBp2L9qzkI4t4cBFJ1efXF8Dwi132RA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + } + } + }, + "bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dev": true, + "requires": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "browserslist": { + "version": "4.21.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", + "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "requires": { + "caniuse-lite": "^1.0.30001449", + "electron-to-chromium": "^1.4.284", + "node-releases": "^2.0.8", + "update-browserslist-db": "^1.0.10" + } + }, + "browserstack": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/browserstack/-/browserstack-1.6.1.tgz", + "integrity": "sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw==", + "dev": true, + "requires": { + "https-proxy-agent": "^2.2.1" + }, + "dependencies": { + "agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dev": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "dev": true, + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + } + } + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "builtins": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", + "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", + "dev": true, + "requires": { + "semver": "^7.0.0" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "cacache": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.0.6.tgz", + "integrity": "sha512-ixcYmEBExFa/+ajIPjcwypxL97CjJyOsH9A/W+4qgEPIpJvKlC+HmVY8nkIck6n3PwUTdgq9c489niJGwl+5Cw==", + "dev": true, + "requires": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.4.tgz", + "integrity": "sha512-fDboBse/sl1oXSLhIp0FcCJgzW9KmhC/q8ULTKC82zc+DL3TL7FNb8qlt5qqXN53MsKEUSIcb+7DLmEygOE5Yw==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.0", + "minipass": "^5.0.0 || ^6.0.0", + "path-scurry": "^1.7.0" + } + }, + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001487", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", + "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==" + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "clean-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==", + "dev": true, + "requires": { + "del": "^4.1.1" + }, + "dependencies": { + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + } + } + }, + "globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true + }, + "is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "requires": { + "is-path-inside": "^2.1.0" + } + }, + "is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "requires": { + "path-is-inside": "^1.0.2" + } + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", + "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", + "dev": true + }, + "cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true + }, + "clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "optional": true, + "requires": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "compress-commons": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.1.tgz", + "integrity": "sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ==", + "dev": true, + "requires": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + } + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true, + "peer": true + } + } + }, + "connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "requires": { + "safe-buffer": "5.2.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "optional": true, + "peer": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "copy-anything": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", + "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", + "dev": true, + "requires": { + "is-what": "^3.14.1" + } + }, + "copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "requires": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "dependencies": { + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + } + } + }, + "core-js-compat": { + "version": "3.30.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.30.2.tgz", + "integrity": "sha512-nriW1nuJjUgvkEjIot1Spwakz52V9YkYHZAQG6A1eCgC8AA1p0zngrQEP9R0+V6hji5XilWKG1Bd0YRppmGimA==", + "dev": true, + "requires": { + "browserslist": "^4.21.5" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "optional": true, + "requires": { + "layout-base": "^1.0.0" + } + }, + "cosmiconfig": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", + "dev": true, + "requires": { + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + } + } + }, + "cosmiconfig-typescript-loader": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz", + "integrity": "sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==", + "dev": true, + "requires": {} + }, + "crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true + }, + "crc32-stream": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.2.tgz", + "integrity": "sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==", + "dev": true, + "requires": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + } + }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "critters": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.16.tgz", + "integrity": "sha512-JwjgmO6i3y6RWtLYmXwO5jMd+maZt8Tnfu7VVISmEWyQqfLpB8soBswf8/2bu6SBXxtKA68Al3c+qIG1ApT68A==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "css-select": "^4.2.0", + "parse5": "^6.0.1", + "parse5-htmlparser2-tree-adapter": "^6.0.1", + "postcss": "^8.3.7", + "pretty-bytes": "^5.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "css-loader": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", + "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.19", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" + }, + "dependencies": { + "semver": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", + "dev": true + }, + "custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=", + "dev": true, + "optional": true, + "peer": true + }, + "cytoscape": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.25.0.tgz", + "integrity": "sha512-7MW3Iz57mCUo6JQCho6CmPBCbTlJr7LzyEtIkutG255HLVd4XuBg2I9BkTZLI/e4HoaOB/BiAzXuQybQ95+r9Q==", + "optional": true, + "requires": { + "heap": "^0.2.6", + "lodash": "^4.17.21" + } + }, + "cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "optional": true, + "requires": { + "cose-base": "^1.0.0" + } + }, + "cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "optional": true, + "requires": { + "cose-base": "^2.2.0" + }, + "dependencies": { + "cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "optional": true, + "requires": { + "layout-base": "^2.0.0" + } + }, + "layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "optional": true + } + } + }, + "d3": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.8.4.tgz", + "integrity": "sha512-q2WHStdhiBtD8DMmhDPyJmXUxr6VWRngKyiJ5EfXMxPw+tqT6BhNjhJZ4w3BHsNm3QoVfZLY8Orq/qPFczwKRA==", + "requires": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + } + }, + "d3-array": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz", + "integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==" + }, + "d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + } + }, + "d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "requires": { + "d3-path": "1 - 3" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-contour": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz", + "integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==", + "requires": { + "d3-array": "^3.2.0" + } + }, + "d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "requires": { + "delaunator": "5" + } + }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "requires": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + } + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "requires": { + "d3-dsv": "1 - 3" + } + }, + "d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" + }, + "d3-geo": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.0.1.tgz", + "integrity": "sha512-Wt23xBych5tSy9IYAM1FR2rWIBFWa52B/oF/GYe5zbdHrg08FU8+BuI6X4PvTwPDdqdAdq04fuWJpELtsaEjeA==", + "requires": { + "d3-array": "2.5.0 - 3" + } + }, + "d3-hierarchy": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz", + "integrity": "sha512-LtAIu54UctRmhGKllleflmHalttH3zkfSi4NlKrTAoFKjC+AFBJohsCAdgCBYQwH0F8hIOGY89X1pPqAchlMkA==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.0.1.tgz", + "integrity": "sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w==" + }, + "d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==" + }, + "d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==" + }, + "d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==", + "requires": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + } + }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" + }, + "d3-shape": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.1.0.tgz", + "integrity": "sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ==", + "requires": { + "d3-path": "1 - 3" + } + }, + "d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } + }, + "dagre-d3-es": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.9.tgz", + "integrity": "sha512-rYR4QfVmy+sR44IBDvVtcAmOReGBvRCWDpO2QjYwqgh9yijw6eSHBqaPG/LIOEy7aBsniLvtMW6pg19qJhq60w==", + "optional": true, + "requires": { + "d3": "^7.8.2", + "lodash-es": "^4.17.21" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "requires": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + } + }, + "date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "requires": { + "@babel/runtime": "^7.21.0" + } + }, + "date-format": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.11.tgz", + "integrity": "sha512-VS20KRyorrbMCQmpdl2hg5KaOUsda1RbnsJg461FfrcyCUg+pkd0b40BSW4niQyTheww4DBXQnS7HwSrKkipLw==", + "dev": true, + "optional": true, + "peer": true + }, + "dayjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", + "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==", + "optional": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "deep-equal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.1.tgz", + "integrity": "sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.0", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.0", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true + }, + "default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "requires": { + "execa": "^5.0.0" + } + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "requires": { + "clone": "^1.0.2" + } + }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, + "define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "delaunator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.0.tgz", + "integrity": "sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==", + "requires": { + "robust-predicates": "^3.0.0" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "optional": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true + }, + "dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true + }, + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=", + "dev": true, + "optional": true, + "peer": true + }, + "didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "dns-packet": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz", + "integrity": "sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ==", + "dev": true, + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + }, + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "requires": { + "domelementtype": "^2.2.0" + } + }, + "dompurify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.3.tgz", + "integrity": "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ==", + "optional": true + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true + }, + "duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, + "eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dev": true, + "requires": { + "jake": "^10.8.5" + } + }, + "electron-to-chromium": { + "version": "1.4.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.396.tgz", + "integrity": "sha512-pqKTdqp/c5vsrc0xUPYXTDBo9ixZuGY8es4ZOjjd6HD6bFYbu5QA09VoW3fkY4LF1T0zYk86lN6bZnNlBuOpdQ==" + }, + "elkjs": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.8.2.tgz", + "integrity": "sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==", + "optional": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "emoji-toolkit": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-7.0.1.tgz", + "integrity": "sha512-l5aJyAhpC5s4mDuoVuqt4SzVjwIsIvakPh4ZGJJE4KWuWFCEHaXacQFkStVdD9zbRR+/BbRXob7u99o0lQFr8A==" + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "optional": true, + "requires": { + "iconv-lite": "^0.6.2" + } + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "engine.io": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.2.tgz", + "integrity": "sha512-FKn/3oMiJjrOEOeUub2WCox6JhxBXq/Zn3fZOMCBxKnNYtsdKjxhl7yR3fZhM9PV+rdE75SU5SYMc+2PGzo+Tg==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.11.0" + } + }, + "engine.io-parser": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz", + "integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==", + "dev": true, + "optional": true, + "peer": true + }, + "enhanced-resolve": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz", + "integrity": "sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", + "dev": true, + "optional": true, + "peer": true + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true + }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "optional": true, + "requires": { + "prr": "~1.0.1" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz", + "integrity": "sha512-oW69R+4q2wG+Hc3KZePPZxOiisRIqfKBVo/HLx94QcJeWGU/8sZhCvc829rd1kS366vlJbzBfXf9yWwf0+Ko7w==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.1", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.0", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.5", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.14" + } + }, + "es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.4" + } + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, + "es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, + "es-module-lexer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", + "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", + "dev": true + }, + "es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "dev": true, + "requires": { + "es6-promise": "^4.0.3" + } + }, + "esbuild": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.18.tgz", + "integrity": "sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==", + "dev": true, + "requires": { + "@esbuild/android-arm": "0.17.18", + "@esbuild/android-arm64": "0.17.18", + "@esbuild/android-x64": "0.17.18", + "@esbuild/darwin-arm64": "0.17.18", + "@esbuild/darwin-x64": "0.17.18", + "@esbuild/freebsd-arm64": "0.17.18", + "@esbuild/freebsd-x64": "0.17.18", + "@esbuild/linux-arm": "0.17.18", + "@esbuild/linux-arm64": "0.17.18", + "@esbuild/linux-ia32": "0.17.18", + "@esbuild/linux-loong64": "0.17.18", + "@esbuild/linux-mips64el": "0.17.18", + "@esbuild/linux-ppc64": "0.17.18", + "@esbuild/linux-riscv64": "0.17.18", + "@esbuild/linux-s390x": "0.17.18", + "@esbuild/linux-x64": "0.17.18", + "@esbuild/netbsd-x64": "0.17.18", + "@esbuild/openbsd-x64": "0.17.18", + "@esbuild/sunos-x64": "0.17.18", + "@esbuild/win32-arm64": "0.17.18", + "@esbuild/win32-ia32": "0.17.18", + "@esbuild/win32-x64": "0.17.18" + } + }, + "esbuild-wasm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.17.18.tgz", + "integrity": "sha512-h4m5zVa+KaDuRFIbH9dokMwovvkIjTQJS7/Ry+0Z1paVuS9aIkso2vdA2GmwH9GSvGX6w71WveJ3PfkoLuWaRw==", + "dev": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", + "@humanwhocodes/config-array": "^0.11.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + } + } + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-visitor-keys": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "dev": true + }, + "espree": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "dev": true, + "requires": { + "acorn": "^8.8.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "dependencies": { + "acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true + }, + "eventemitter-asyncresource": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz", + "integrity": "sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dev": true, + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "requires": { + "minimatch": "^5.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true, + "peer": true + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.6.tgz", + "integrity": "sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==", + "dev": true + }, + "follow-redirects": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "dev": true + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, + "foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "dependencies": { + "signal-exit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", + "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "dev": true + } + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true + }, + "fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "fs-minipass": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.2.tgz", + "integrity": "sha512-2GAfyfoaCDRrM6jaOS3UsBts8yJ55VioXdWcOL7dK9zdAuKT71+WBA4ifnNYqVjYv+4SsPxjK0JT4yIIn4cA/g==", + "dev": true, + "requires": { + "minipass": "^5.0.0" + } + }, + "fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + } + }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true + }, + "fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==" + }, + "gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "dev": true, + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + } + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "globby": { + "version": "13.1.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.1.4.tgz", + "integrity": "sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==", + "dev": true, + "requires": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.11", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^4.0.0" + } + }, + "good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "optional": true, + "requires": { + "delegate": "^3.1.2" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "graceful-fs": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", + "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", + "dev": true + }, + "grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true + }, + "gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "requires": { + "duplexer": "^0.1.2" + } + }, + "handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "dev": true, + "requires": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + } + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + } + } + }, + "has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0" + } + }, + "has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.3" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true + }, + "hasown": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "hdr-histogram-js": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz", + "integrity": "sha512-Hkn78wwzWHNCp2uarhzQ2SGFLU3JY8SBDDd3TAABK4fc30wm+MuPOrg5QVFVfkKOQd6Bfz3ukJEI+q9sXEkK1g==", + "dev": true, + "requires": { + "@assemblyscript/loader": "^0.10.1", + "base64-js": "^1.2.0", + "pako": "^1.0.3" + } + }, + "hdr-histogram-percentiles-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz", + "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", + "dev": true + }, + "heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", + "optional": true + }, + "hosted-git-info": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz", + "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==", + "dev": true, + "requires": { + "lru-cache": "^7.5.1" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + } + } + }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "dependencies": { + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + } + } + }, + "http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "requires": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + } + }, + "http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "requires": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "requires": { + "ms": "^2.0.0" + } + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "requires": {} + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, + "ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true + }, + "ignore-walk": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.3.tgz", + "integrity": "sha512-C7FfFoTA+bI10qfeydT8aZbvr91vAEU+2W5BZUlzPec47oNb07SsOfwYrtxuvOYdUApPP/Qlh4DtAO51Ekk2QA==", + "dev": true, + "requires": { + "minimatch": "^9.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "optional": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true + }, + "immutable": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.0.tgz", + "integrity": "sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.0.0.tgz", + "integrity": "sha512-t0ikzf5qkSFqRl1e6ejKBe+Tk2bsQd8ivEkcisyGXsku2t8NvXZ1Y3RRz5vxrDgOrTBOi13CvGsVoI5wVpd7xg==", + "dev": true + }, + "injection-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/injection-js/-/injection-js-2.4.0.tgz", + "integrity": "sha512-6jiJt0tCAo9zjHbcwLiPL+IuNe9SQ6a9g0PEzafThW3fOQi0mrmiJGBJvDD6tmhPh8cQHIQtCOrJuBfQME4kPA==", + "dev": true, + "requires": { + "tslib": "^2.0.0" + } + }, + "inquirer": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.4.tgz", + "integrity": "sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + } + }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, + "ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "dev": true + }, + "ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "dev": true + }, + "is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "requires": { + "builtin-modules": "^3.3.0" + }, + "dependencies": { + "builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true + } + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true + }, + "is-core-module": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", + "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true + }, + "is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true + }, + "is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "requires": { + "call-bind": "^1.0.7" + } + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "requires": { + "which-typed-array": "^1.1.14" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, + "is-what": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", + "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isbinaryfile": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.8.tgz", + "integrity": "sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==", + "dev": true, + "optional": true, + "peer": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "jackspeak": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.0.tgz", + "integrity": "sha512-r5XBrqIJfwRIjRt/Xr5fv9Wh09qyhHfKnYddDlpM+ibRR20qrYActpCAgU6U+d53EOEjzkvxPMVHSlgR7leXrQ==", + "dev": true, + "requires": { + "@isaacs/cliui": "^8.0.2", + "@pkgjs/parseargs": "^0.11.0" + } + }, + "jake": { + "version": "10.8.6", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.6.tgz", + "integrity": "sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA==", + "dev": true, + "requires": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jasmine": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-2.8.0.tgz", + "integrity": "sha1-awicChFXax8W3xG4AUbZHU6Lij4=", + "dev": true, + "requires": { + "exit": "^0.1.2", + "glob": "^7.0.6", + "jasmine-core": "~2.8.0" + }, + "dependencies": { + "jasmine-core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.8.0.tgz", + "integrity": "sha1-vMl5rh+f0FcB5F5S5l06XWPxok4=", + "dev": true + } + } + }, + "jasmine-core": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.0.0.tgz", + "integrity": "sha512-BJLxZlSVyWPN/oyaS1IIvIjChghI9/xWsLAIJqL9J5Fz47CN3JNr8Lmik3S2S7QS2RxclYjvSVSXP7IR35PAmg==", + "dev": true + }, + "jasmine-spec-reporter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz", + "integrity": "sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==", + "dev": true, + "requires": { + "colors": "1.4.0" + } + }, + "jasminewd2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jasminewd2/-/jasminewd2-2.2.0.tgz", + "integrity": "sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4=", + "dev": true + }, + "jest-worker": { + "version": "27.4.6", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.4.6.tgz", + "integrity": "sha512-gHWJF/6Xi5CTG5QCvROr6GcmpIqNYpDJyc8A1h/DyXqH1tD6SnRCM0d3U5msV31D2LB/U+E0M+W4oyvKV44oNw==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jiti": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", + "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "dev": true + }, + "js-sdsl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", + "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "js-yaml-loader": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/js-yaml-loader/-/js-yaml-loader-1.2.2.tgz", + "integrity": "sha512-H+NeuNrG6uOs/WMjna2SjkaCw13rMWiT/D7l9+9x5n8aq88BDsh2sRmdfxckWPIHtViYHWRG6XiCKYvS1dfyLg==", + "dev": true, + "requires": { + "js-yaml": "^3.13.1", + "loader-utils": "^1.2.3", + "un-eval": "^1.2.0" + }, + "dependencies": { + "json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + } + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + }, + "jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true + }, + "jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "karma": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", + "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.4.1", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "minimist": "^1.2.6" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "peer": true + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "rimraf": "^3.0.0" + } + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "optional": true, + "peer": true + } + } + }, + "karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "dev": true, + "requires": { + "source-map-support": "^0.5.5" + } + }, + "katex": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.0.tgz", + "integrity": "sha512-wPRB4iUPysfH97wTgG5/tRLYxmKVq6Q4jRAWRVOUxXB1dsiv4cvcNjqabHkrOvJHM1Bpk3WrgmllSO1vIvP24w==", + "optional": true, + "requires": { + "commander": "^8.0.0" + }, + "dependencies": { + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "optional": true + } + } + }, + "khroma": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.0.0.tgz", + "integrity": "sha512-2J8rDNlQWbtiNYThZRvmMv5yt44ZakX+Tz5ZIp/mN1pt4snn+m030Va5Z4v8xA0cQFDXBwO/8i42xL4QPsVk3g==", + "optional": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true + }, + "launch-editor": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.0.tgz", + "integrity": "sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==", + "dev": true, + "requires": { + "picocolors": "^1.0.0", + "shell-quote": "^1.7.3" + } + }, + "layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "optional": true + }, + "lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "requires": { + "readable-stream": "^2.0.5" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "less": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz", + "integrity": "sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==", + "dev": true, + "requires": { + "copy-anything": "^2.0.1", + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "parse-node-version": "^1.0.1", + "source-map": "~0.6.0", + "tslib": "^2.3.0" + }, + "dependencies": { + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "optional": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "optional": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "optional": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "optional": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "less-loader": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-11.1.0.tgz", + "integrity": "sha512-C+uDBV7kS7W5fJlUjq5mPBeBVhYpTIm5gB09APT9o3n/ILeaXVsiSFTbZpTJCJwQ/Crczfn3DmfQFwxYusWFug==", + "dev": true, + "requires": { + "klona": "^2.0.4" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "dev": true, + "requires": { + "webpack-sources": "^3.0.0" + } + }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "requires": { + "immediate": "~3.0.5" + } + }, + "lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + } + } + }, + "loader-runner": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", + "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "dev": true + }, + "loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "devOptional": true + }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "optional": true + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true + }, + "lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "log4js": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.6.0.tgz", + "integrity": "sha512-3v8R7fd45UB6THucSht6wN2/7AZEruQbXdjygPZcxt5TA/msO6si9CN5MefUuKXbYnJHTBnYcx4famwcyQd+sA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "date-format": "^4.0.11", + "debug": "^4.3.4", + "flatted": "^3.2.5", + "rfdc": "^1.3.0", + "streamroller": "^3.1.1" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.4.13" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "requires": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "dependencies": { + "@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "requires": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "requires": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + } + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "requires": { + "minipass": "^3.1.1" + } + }, + "unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "requires": { + "unique-slug": "^3.0.0" + } + }, + "unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + } + } + }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "peer": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "memfs": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.1.tgz", + "integrity": "sha512-UWbFJKvj5k+nETdteFndTpYxdeTMox/ULeqX5k/dpaQJCCFmj5EeKv3dBcyO2xmkRAx2vppRu5dVG7SOtsGOzA==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.3" + } + }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "mermaid": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-9.4.3.tgz", + "integrity": "sha512-TLkQEtqhRSuEHSE34lh5bCa94KATCyluAXmFnNI2PRZwOpXFeqiJWwZl+d2CcemE1RS6QbbueSSq9QIg8Uxcyw==", + "optional": true, + "requires": { + "@braintree/sanitize-url": "^6.0.0", + "cytoscape": "^3.23.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.1.0", + "d3": "^7.4.0", + "dagre-d3-es": "7.0.9", + "dayjs": "^1.11.7", + "dompurify": "2.4.3", + "elkjs": "^0.8.2", + "khroma": "^2.0.0", + "lodash-es": "^4.17.21", + "non-layered-tidy-tree-layout": "^2.0.2", + "stylis": "^4.1.2", + "ts-dedent": "^2.2.0", + "uuid": "^9.0.0", + "web-worker": "^1.2.0" + }, + "dependencies": { + "uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "optional": true + } + } + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "optional": true, + "peer": true + }, + "mime-db": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", + "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==", + "dev": true + }, + "mime-types": { + "version": "2.1.34", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", + "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "dev": true, + "requires": { + "mime-db": "1.51.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mini-css-extract-plugin": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.5.tgz", + "integrity": "sha512-9HaR++0mlgom81s95vvNjxkg52n2b5s//3ZTI1EtzFb98awsLSivs2LMsVqnQ3ay0PVhqWcGNyDaTE961FOcjQ==", + "dev": true, + "requires": { + "schema-utils": "^4.0.0" + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + }, + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true + }, + "minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "requires": { + "encoding": "^0.1.13", + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-json-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", + "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", + "dev": true, + "requires": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true + }, + "needle": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.2.0.tgz", + "integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==", + "dev": true, + "optional": true, + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "optional": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "ng-packagr": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-16.0.1.tgz", + "integrity": "sha512-MiJvSR+8olzCViwkQ6ihHLFWVNLdsfUNPCxrZqR7u1nOC/dXlWPf//l2IG0KLdVhHNCiM64mNdwaTpgDEBMD3w==", + "dev": true, + "requires": { + "@rollup/plugin-json": "^6.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "ajv": "^8.11.0", + "ansi-colors": "^4.1.3", + "autoprefixer": "^10.4.12", + "browserslist": "^4.21.4", + "cacache": "^17.0.0", + "chokidar": "^3.5.3", + "commander": "^10.0.0", + "convert-source-map": "^2.0.0", + "dependency-graph": "^0.11.0", + "esbuild": "^0.17.0", + "esbuild-wasm": "^0.17.0", + "fast-glob": "^3.2.12", + "find-cache-dir": "^3.3.2", + "injection-js": "^2.4.0", + "jsonc-parser": "^3.2.0", + "less": "^4.1.3", + "ora": "^5.1.0", + "piscina": "^3.2.0", + "postcss": "^8.4.16", + "postcss-url": "^10.1.3", + "rollup": "^3.0.0", + "rxjs": "^7.5.6", + "sass": "^1.55.0" + }, + "dependencies": { + "commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + } + } + }, + "ng-zorro-antd": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/ng-zorro-antd/-/ng-zorro-antd-16.1.0.tgz", + "integrity": "sha512-+KjXoA0+v/liTtVIHswmOAzB9UaGADrO1tL9AOZsTLq5sZM8+DmhtixGRoSMD8HkkhpMFhsgEIxoHlkxtn1SXg==", + "requires": { + "@angular/cdk": "^16.0.0", + "@ant-design/icons-angular": "^16.0.0", + "date-fns": "^2.16.1", + "tslib": "^2.3.0" + } + }, + "ngx-markdown": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-16.0.0.tgz", + "integrity": "sha512-/rlbXi+HBscJCDdwaTWIUrRkvwJicPnuAgeugOCZa0UbZ4VCWV3U0+uB1Zv6krRDF6FXJNXNLTUrMZV7yH8I6A==", + "requires": { + "clipboard": "^2.0.11", + "emoji-toolkit": "^7.0.0", + "katex": "^0.16.0", + "mermaid": "^9.1.2", + "prismjs": "^1.28.0", + "tslib": "^2.3.0" + } + }, + "nice-napi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", + "integrity": "sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==", + "dev": true, + "optional": true, + "requires": { + "node-addon-api": "^3.0.0", + "node-gyp-build": "^4.2.2" + } + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "dev": true + }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true + }, + "node-gyp": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.3.1.tgz", + "integrity": "sha512-4Q16ZCqq3g8awk6UplT7AuxQ35XN4R/yf/+wSAwcBUAjg7l58RTactWaP8fIDTi0FzI7YcVLujwExakZlfWkXg==", + "dev": true, + "requires": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "node-gyp-build": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", + "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", + "dev": true + }, + "node-releases": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", + "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==" + }, + "non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "optional": true + }, + "nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "requires": { + "abbrev": "^1.0.0" + } + }, + "normalize-package-data": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz", + "integrity": "sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==", + "dev": true, + "requires": { + "hosted-git-info": "^6.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=" + }, + "npm-bundled": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", + "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "dev": true, + "requires": { + "npm-normalize-package-bin": "^3.0.0" + } + }, + "npm-install-checks": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.1.1.tgz", + "integrity": "sha512-dH3GmQL4vsPtld59cOn8uY0iOqRmqKvV+DLGwNXV/Q7MDgD2QfOADWd/mFXcIE5LVhYYGjA3baz6W9JneqnuCw==", + "dev": true, + "requires": { + "semver": "^7.1.1" + } + }, + "npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true + }, + "npm-package-arg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-10.1.0.tgz", + "integrity": "sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==", + "dev": true, + "requires": { + "hosted-git-info": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + } + }, + "npm-packlist": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-7.0.4.tgz", + "integrity": "sha512-d6RGEuRrNS5/N84iglPivjaJPxhDbZmlbTwTDX2IbcRHG5bZCdtysYMhwiPvcF4GisXHGn7xsxv+GQ7T/02M5Q==", + "dev": true, + "requires": { + "ignore-walk": "^6.0.0" + } + }, + "npm-pick-manifest": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-8.0.1.tgz", + "integrity": "sha512-mRtvlBjTsJvfCCdmPtiu2bdlx8d/KXtF7yNXNWe7G0Z36qWA9Ny5zXsI2PfBZEv7SXgoxTmNaTzGSbbzDZChoA==", + "dev": true, + "requires": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^10.0.0", + "semver": "^7.3.5" + } + }, + "npm-registry-fetch": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-14.0.5.tgz", + "integrity": "sha512-kIDMIo4aBm6xg7jOttupWZamsZRkAqMqwqqbVXnUqstY5+tapvv6bkH/qMR76jdgV+YljEUCyWx3hRYMrJiAgA==", + "dev": true, + "requires": { + "make-fetch-happen": "^11.0.0", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^10.0.0", + "proc-log": "^3.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "requires": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + } + }, + "minipass-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.3.tgz", + "integrity": "sha512-n5ITsTkDqYkYJZjcRWzZt9qnZKCT7nKCosJhHoj7S7zD+BP4jVbWs+odsniw5TA3E0sLomhTKOKjF86wf11PuQ==", + "dev": true, + "requires": { + "encoding": "^0.1.13", + "minipass": "^5.0.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + } + } + } + }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + } + } + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "dev": true, + "requires": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + } + }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "nx": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/nx/-/nx-16.0.2.tgz", + "integrity": "sha512-8Z9Bo1D2VbYjyC/F2ONensKjm10snz1UfkzURZiFA+oXikBPldiH1u67TOTpoCYZfyYQg4l6h6EpOaAvHF6Abg==", + "dev": true, + "requires": { + "@nrwl/tao": "16.0.2", + "@nx/nx-darwin-arm64": "16.0.2", + "@nx/nx-darwin-x64": "16.0.2", + "@nx/nx-linux-arm-gnueabihf": "16.0.2", + "@nx/nx-linux-arm64-gnu": "16.0.2", + "@nx/nx-linux-arm64-musl": "16.0.2", + "@nx/nx-linux-x64-gnu": "16.0.2", + "@nx/nx-linux-x64-musl": "16.0.2", + "@nx/nx-win32-arm64-msvc": "16.0.2", + "@nx/nx-win32-x64-msvc": "16.0.2", + "@parcel/watcher": "2.0.4", + "@yarnpkg/lockfile": "^1.1.0", + "@yarnpkg/parsers": "^3.0.0-rc.18", + "@zkochan/js-yaml": "0.0.6", + "axios": "^1.0.0", + "chalk": "^4.1.0", + "cli-cursor": "3.1.0", + "cli-spinners": "2.6.1", + "cliui": "^7.0.2", + "dotenv": "~10.0.0", + "enquirer": "~2.3.6", + "fast-glob": "3.2.7", + "figures": "3.2.0", + "flat": "^5.0.2", + "fs-extra": "^11.1.0", + "glob": "7.1.4", + "ignore": "^5.0.4", + "js-yaml": "4.1.0", + "jsonc-parser": "3.2.0", + "lines-and-columns": "~2.0.3", + "minimatch": "3.0.5", + "npm-run-path": "^4.0.1", + "open": "^8.4.0", + "semver": "7.3.4", + "string-width": "^4.2.3", + "strong-log-transformer": "^2.1.0", + "tar-stream": "~2.2.0", + "tmp": "~0.2.1", + "tsconfig-paths": "^4.1.2", + "tslib": "^2.3.0", + "v8-compile-cache": "2.3.0", + "yargs": "^17.6.2", + "yargs-parser": "21.1.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "fast-glob": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", + "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "lines-and-columns": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", + "integrity": "sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "requires": { + "rimraf": "^3.0.0" + } + } + } + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true + }, + "object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true + }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + } + }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "requires": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + } + }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "requires": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + }, + "dependencies": { + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + } + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "requires": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "dependencies": { + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true + } + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pacote": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.1.3.tgz", + "integrity": "sha512-aRts8cZqxiJVDitmAh+3z+FxuO3tLNWEmwDRPEpDDiZJaRz06clP4XX112ynMT5uF0QNoMPajBBHnaStUEPJXA==", + "dev": true, + "requires": { + "@npmcli/git": "^4.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/promise-spawn": "^6.0.1", + "@npmcli/run-script": "^6.0.0", + "cacache": "^17.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^5.0.0", + "npm-package-arg": "^10.0.0", + "npm-packlist": "^7.0.0", + "npm-pick-manifest": "^8.0.0", + "npm-registry-fetch": "^14.0.0", + "proc-log": "^3.0.0", + "promise-retry": "^2.0.1", + "read-package-json": "^6.0.0", + "read-package-json-fast": "^3.0.0", + "sigstore": "^1.3.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + } + }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true + }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "devOptional": true, + "requires": { + "entities": "^4.4.0" + }, + "dependencies": { + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "devOptional": true + } + } + }, + "parse5-html-rewriting-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", + "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", + "dev": true, + "requires": { + "entities": "^4.3.0", + "parse5": "^7.0.0", + "parse5-sax-parser": "^7.0.0" + }, + "dependencies": { + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true + } + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "requires": { + "parse5": "^6.0.1" + }, + "dependencies": { + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + } + } + }, + "parse5-sax-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", + "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", + "dev": true, + "requires": { + "parse5": "^7.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-scurry": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.9.1.tgz", + "integrity": "sha512-UgmoiySyjFxP6tscZDgWGEAgsW5ok8W3F5CJDnnH2pozwSTGE6eH7vwTotMwATWA2r5xqdkKdxYPkwlJjAI/3g==", + "dev": true, + "requires": { + "lru-cache": "^9.1.1", + "minipass": "^5.0.0 || ^6.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz", + "integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==", + "dev": true + } + } + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pirates": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", + "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "dev": true + }, + "piscina": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-3.2.0.tgz", + "integrity": "sha512-yn/jMdHRw+q2ZJhFhyqsmANcbF6V2QwmD84c6xRau+QpQOmtrBCoRGdvTfeuFDYXB5W2m6MfLkjkvQa9lUSmIA==", + "dev": true, + "requires": { + "eventemitter-asyncresource": "^1.0.0", + "hdr-histogram-js": "^2.0.1", + "hdr-histogram-percentiles-obj": "^3.0.0", + "nice-napi": "^1.0.2" + } + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true + }, + "postcss": { + "version": "8.4.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", + "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "requires": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + } + }, + "postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "requires": { + "camelcase-css": "^2.0.1" + } + }, + "postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dev": true, + "requires": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + } + }, + "postcss-loader": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.0.tgz", + "integrity": "sha512-qLAFjvR2BFNz1H930P7mj1iuWJFjGey/nVhimfOAAQ1ZyPpcClAxP8+A55Sl8mBvM+K2a9Pjgdj10KpANWrNfw==", + "dev": true, + "requires": { + "cosmiconfig": "^8.1.3", + "jiti": "^1.18.2", + "klona": "^2.0.6", + "semver": "^7.3.8" + }, + "dependencies": { + "semver": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "requires": {} + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.11" + } + }, + "postcss-scss": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.6.tgz", + "integrity": "sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==", + "dev": true, + "requires": {} + }, + "postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-url": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.3.tgz", + "integrity": "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==", + "dev": true, + "requires": { + "make-dir": "~3.1.0", + "mime": "~2.5.2", + "minimatch": "~3.0.4", + "xxhashjs": "~0.2.2" + }, + "dependencies": { + "mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "dev": true + } + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true + }, + "prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==" + }, + "proc-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", + "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, + "protractor": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz", + "integrity": "sha512-UqkFjivi4GcvUQYzqGYNe0mLzfn5jiLmO8w9nMhQoJRLhy2grJonpga2IWhI6yJO30LibWXJJtA4MOIZD2GgZw==", + "dev": true, + "requires": { + "@types/q": "^0.0.32", + "@types/selenium-webdriver": "^3.0.0", + "blocking-proxy": "^1.0.0", + "browserstack": "^1.5.1", + "chalk": "^1.1.3", + "glob": "^7.0.3", + "jasmine": "2.8.0", + "jasminewd2": "^2.1.0", + "q": "1.4.1", + "saucelabs": "^1.5.0", + "selenium-webdriver": "3.6.0", + "source-map-support": "~0.4.0", + "webdriver-js-extender": "2.1.0", + "webdriver-manager": "^12.1.7", + "yargs": "^15.3.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "dependencies": { + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true + } + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "optional": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true + }, + "psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "purgecss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-5.0.0.tgz", + "integrity": "sha512-RAnuxrGuVyLLTr8uMbKaxDRGWMgK5CCYDfRyUNNcaz5P3kGgD2b7ymQGYEyo2ST7Tl/ScwFgf5l3slKMxHSbrw==", + "dev": true, + "requires": { + "commander": "^9.0.0", + "glob": "^8.0.3", + "postcss": "^8.4.4", + "postcss-selector-parser": "^6.0.7" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true + }, + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "q": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz", + "integrity": "sha1-VXBbzZPF82c1MMLCy8DCs63cKG4=", + "dev": true + }, + "qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "optional": true, + "peer": true + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } + } + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=", + "dev": true, + "requires": { + "pify": "^2.3.0" + } + }, + "read-package-json": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-6.0.3.tgz", + "integrity": "sha512-4QbpReW4kxFgeBQ0vPAqh2y8sXEB3D4t3jsXbJKIhBiF80KT6XRo45reqwtftju5J6ru1ax06A2Gb/wM1qCOEQ==", + "dev": true, + "requires": { + "glob": "^10.2.2", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^5.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "glob": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.4.tgz", + "integrity": "sha512-fDboBse/sl1oXSLhIp0FcCJgzW9KmhC/q8ULTKC82zc+DL3TL7FNb8qlt5qqXN53MsKEUSIcb+7DLmEygOE5Yw==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.0", + "minipass": "^5.0.0 || ^6.0.0", + "path-scurry": "^1.7.0" + } + }, + "json-parse-even-better-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", + "integrity": "sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==", + "dev": true + }, + "minimatch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz", + "integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "read-package-json-fast": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", + "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", + "dev": true, + "requires": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "dependencies": { + "json-parse-even-better-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", + "integrity": "sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==", + "dev": true + } + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "dependencies": { + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdir-glob": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.2.tgz", + "integrity": "sha512-6RLVvwJtVwEDfPdn6X6Ille4/lxGl0ATOY4FN/B9nxQcgOazvvI0nodiD19ScKq0PvA/29VpaOQML36o5IzZWA==", + "dev": true, + "requires": { + "minimatch": "^5.1.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.2" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "regenerator-transform": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", + "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regex-parser": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", + "dev": true + }, + "regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + } + }, + "regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "requires": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + } + }, + "regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true + } + } + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "dev": true + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "resolve": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", + "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "dev": true, + "requires": { + "is-core-module": "^2.11.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "dev": true, + "requires": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true, + "optional": true, + "peer": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "robust-predicates": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.1.tgz", + "integrity": "sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==" + }, + "rollup": { + "version": "3.21.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.21.8.tgz", + "integrity": "sha512-SSFV2T2fWtQ/vvBip85u2Nr0GNKireabH9d7nXswBg+XSH+jbVDSYptRAEbCEsquhs503rpPA9POYAp0/Jhasw==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" + }, + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "requires": { + "tslib": "^2.1.0" + } + }, + "safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sass": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.62.1.tgz", + "integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==", + "dev": true, + "requires": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + } + }, + "sass-loader": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.2.2.tgz", + "integrity": "sha512-nrIdVAAte3B9icfBiGWvmMhT/D+eCDwnk+yA7VE/76dp/WkHX+i44Q/pfo71NYbwj0Ap+PGsn0ekOuU1WFJ2AA==", + "dev": true, + "requires": { + "klona": "^2.0.6", + "neo-async": "^2.6.2" + } + }, + "saucelabs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/saucelabs/-/saucelabs-1.5.0.tgz", + "integrity": "sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ==", + "dev": true, + "requires": { + "https-proxy-agent": "^2.2.1" + }, + "dependencies": { + "agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dev": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "dev": true, + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + } + } + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "schema-utils": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", + "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + }, + "select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "optional": true + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "selenium-webdriver": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-3.6.0.tgz", + "integrity": "sha512-WH7Aldse+2P5bbFBO4Gle/nuQOdVwpHMTL6raL3uuBj/vPG07k6uzt3aiahu352ONBr5xXh0hDlM3LhtXPOC4Q==", + "dev": true, + "requires": { + "jszip": "^3.1.3", + "rimraf": "^2.5.4", + "tmp": "0.0.30", + "xml2js": "^0.4.17" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "tmp": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz", + "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.1" + } + } + } + }, + "selfsigned": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", + "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dev": true, + "requires": { + "node-forge": "^1" + } + }, + "semver": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", + "integrity": "sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + } + } + }, + "serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + } + } + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-function-length": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dev": true, + "requires": { + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + } + }, + "set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "sigstore": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-1.5.1.tgz", + "integrity": "sha512-FIPThk7S1oeFXn8O8yh7gpyiQb6lYXzMIlOBzXhId/f81VvU587xNCHc4jd2lZ9724UkKUYYTuKSYcjhDSRD/Q==", + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.1.0", + "make-fetch-happen": "^11.0.1", + "tuf-js": "^1.1.3" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "requires": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + } + }, + "minipass-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.3.tgz", + "integrity": "sha512-n5ITsTkDqYkYJZjcRWzZt9qnZKCT7nKCosJhHoj7S7zD+BP4jVbWs+odsniw5TA3E0sLomhTKOKjF86wf11PuQ==", + "dev": true, + "requires": { + "encoding": "^0.1.13", + "minipass": "^5.0.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + } + } + } + }, + "sirv": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz", + "integrity": "sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^1.0.0" + } + }, + "slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true + }, + "smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true + }, + "socket.io": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.6.1.tgz", + "integrity": "sha512-KMcaAi4l/8+xEjkRICl6ak8ySoxsYG+gG6/XfRCPJPQ/haCRIJBTL4wIl8YCsmtaBovcAXGLOShyVWQ/FG8GZA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.2", + "engine.io": "~6.4.1", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.1" + } + }, + "socket.io-adapter": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "ws": "~8.11.0" + } + }, + "socket.io-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz", + "integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, + "sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "requires": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dev": true, + "requires": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "requires": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + } + }, + "source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, + "source-map-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-4.0.1.tgz", + "integrity": "sha512-oqXpzDIByKONVY8g1NUPOTQhe0UTU5bWUl32GSkqK2LjJj0HmwTMVKxcUip0RgAYhY1mqgOxjbQM48a0mmeNfA==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + } + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", + "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "dev": true + }, + "spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + } + }, + "spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "sshpk": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", + "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "ssri": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.4.tgz", + "integrity": "sha512-12+IR2CB2C28MMAw0Ncqwj5QbTcs0nGIhgJzYWzDkb21vWmfNI83KS4f3Ci6GI98WreIfG7o9UXp3C0qbpA8nQ==", + "dev": true, + "requires": { + "minipass": "^5.0.0" + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "dev": true + }, + "stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "requires": { + "internal-slot": "^1.0.4" + } + }, + "streamroller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.1.tgz", + "integrity": "sha512-iPhtd9unZ6zKdWgMeYGfSBuqCngyJy1B/GPi/lTpwGpa3bajuX30GjUVd0/Tn/Xhg0mr4DOSENozz9Y06qyonQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "date-format": "^4.0.10", + "debug": "^4.3.4", + "fs-extra": "^10.1.0" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "string-width-cjs": { + "version": "npm:string-width@4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-ansi-cjs": { + "version": "npm:strip-ansi@6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "strong-log-transformer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz", + "integrity": "sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA==", + "dev": true, + "requires": { + "duplexer": "^0.1.1", + "minimist": "^1.2.0", + "through": "^2.3.4" + } + }, + "stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "optional": true + }, + "stylus": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.59.0.tgz", + "integrity": "sha512-lQ9w/XIOH5ZHVNuNbWW8D822r+/wBSO/d6XvtyHLF7LW4KaCIDeVbvn5DF8fGCJAUCwVhVi/h6J0NUcnylUEjg==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@adobe/css-tools": "^4.0.1", + "debug": "^4.3.2", + "glob": "^7.1.6", + "sax": "~1.2.4", + "source-map": "^0.7.3" + } + }, + "sucrase": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.32.0.tgz", + "integrity": "sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "dependencies": { + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true + }, + "tailwindcss": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", + "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "dev": true, + "requires": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "postcss-value-parser": "^4.2.0", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "dependencies": { + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + } + } + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "tar": { + "version": "6.1.14", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.14.tgz", + "integrity": "sha512-piERznXu0U7/pW7cdSn7hjqySIVTYT6F76icmFk7ptU7dDYlXTm5r9A6K04R2vU3olYgoKeo1Cg3eeu5nhftAw==", + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + } + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, + "terser": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.1.tgz", + "integrity": "sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "acorn": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", + "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz", + "integrity": "sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.17", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.16.8" + }, + "dependencies": { + "acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "terser": { + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", + "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + } + } + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "optional": true + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true + }, + "topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "requires": { + "commander": "2" + } + }, + "topojson-simplify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/topojson-simplify/-/topojson-simplify-3.0.3.tgz", + "integrity": "sha512-V+pBjLVzSQ3+hSOxBiV01OVXgFiCmMO8ia3huxKEyIMTC1ApQHBcdXdOqcQ6U2JJJD31TZduwY6KyF15R8sUgg==", + "requires": { + "commander": "2", + "topojson-client": "3" + } + }, + "totalist": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", + "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==", + "dev": true + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "requires": { + "punycode": "^2.3.1" + } + }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true + }, + "ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "optional": true + }, + "ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "dependencies": { + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + } + } + }, + "tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "requires": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, + "tslint": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz", + "integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^4.0.1", + "glob": "^7.1.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.3", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.13.0", + "tsutils": "^2.29.0" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } + } + }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } + } + }, + "tuf-js": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-1.1.6.tgz", + "integrity": "sha512-CXwFVIsXGbVY4vFiWF7TJKWmlKJAT8TWkH4RmiohJRcDJInix++F0dznDmoVbtJNzZ8yLprKUG4YrDIhv3nBMg==", + "dev": true, + "requires": { + "@tufjs/models": "1.0.4", + "debug": "^4.3.4", + "make-fetch-happen": "^11.1.0" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "requires": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + } + }, + "minipass-fetch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.3.tgz", + "integrity": "sha512-n5ITsTkDqYkYJZjcRWzZt9qnZKCT7nKCosJhHoj7S7zD+BP4jVbWs+odsniw5TA3E0sLomhTKOKjF86wf11PuQ==", + "dev": true, + "requires": { + "encoding": "^0.1.13", + "minipass": "^5.0.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + } + } + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + } + }, + "typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + } + }, + "typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + } + }, + "typed-array-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.5.tgz", + "integrity": "sha512-yMi0PlwuznKHxKmcpoOdeLwxBoVPkqZxd7q2FgMkmD3bNwvF5VW0+UlUQ1k1vmktTu4Yu13Q0RIxEP8+B+wloA==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + } + }, + "typed-assert": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true + }, + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" + }, + "ua-parser-js": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", + "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==", + "dev": true, + "optional": true, + "peer": true + }, + "un-eval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/un-eval/-/un-eval-1.2.0.tgz", + "integrity": "sha512-Wlj/pum6dQtGTPD/lclDtoVPkSfpjPfy1dwnnKw/sZP5DpBH9fLhBgQfsqNhe5/gS1D+vkZUuB771NRMUPA5CA==", + "dev": true + }, + "unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true + }, + "unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "requires": { + "unique-slug": "^4.0.0" + } + }, + "unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", + "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "useragent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.3.0.tgz", + "integrity": "sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==", + "dev": true, + "requires": { + "lru-cache": "4.1.x", + "tmp": "0.0.x" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validate-npm-package-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", + "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", + "dev": true, + "requires": { + "builtins": "^5.0.0" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==", + "dev": true, + "requires": { + "esbuild": "^0.17.5", + "fsevents": "~2.3.2", + "postcss": "^8.4.21", + "rollup": "^3.20.2" + } + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", + "dev": true, + "optional": true, + "peer": true + }, + "watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "requires": { + "minimalistic-assert": "^1.0.0" + } + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, + "requires": { + "defaults": "^1.0.3" + } + }, + "web-worker": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", + "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "optional": true + }, + "webdriver-js-extender": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", + "integrity": "sha512-lcUKrjbBfCK6MNsh7xaY2UAUmZwe+/ib03AjVOpFobX4O7+83BUveSrLfU0Qsyb1DaKJdQRbuU+kM9aZ6QUhiQ==", + "dev": true, + "requires": { + "@types/selenium-webdriver": "^3.0.0", + "selenium-webdriver": "^3.0.1" + } + }, + "webdriver-manager": { + "version": "12.1.9", + "resolved": "https://registry.npmjs.org/webdriver-manager/-/webdriver-manager-12.1.9.tgz", + "integrity": "sha512-Yl113uKm8z4m/KMUVWHq1Sjtla2uxEBtx2Ue3AmIlnlPAKloDn/Lvmy6pqWCUersVISpdMeVpAaGbNnvMuT2LQ==", + "dev": true, + "requires": { + "adm-zip": "^0.5.2", + "chalk": "^1.1.1", + "del": "^2.2.0", + "glob": "^7.0.3", + "ini": "^1.3.4", + "minimist": "^1.2.0", + "q": "^1.4.1", + "request": "^2.87.0", + "rimraf": "^2.5.2", + "semver": "^5.3.0", + "xml2js": "^0.4.17" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "webextension-polyfill": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.8.0.tgz", + "integrity": "sha512-a19+DzlT6Kp9/UI+mF9XQopeZ+n2ussjhxHJ4/pmIGge9ijCDz7Gn93mNnjpZAk95T4Tae8iHZ6sSf869txqiQ==", + "dev": true + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "webpack": { + "version": "5.80.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.80.0.tgz", + "integrity": "sha512-OIMiq37XK1rWO8mH9ssfFKZsXg4n6klTEDL7S8/HqbAOBBaiy8ABvXvz0dDCXeEF9gqwxSvVk611zFPjS8hJxA==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.13.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "dependencies": { + "acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "schema-utils": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.2.tgz", + "integrity": "sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-bundle-analyzer": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.8.0.tgz", + "integrity": "sha512-ZzoSBePshOKhr+hd8u6oCkZVwpVaXgpw23ScGLFpR6SjYI7+7iIWYarjN6OEYOfRt8o7ZyZZQk0DuMizJ+LEIg==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "opener": "^1.5.2", + "sirv": "^1.0.7", + "ws": "^7.3.1" + }, + "dependencies": { + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "ws": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.6.tgz", + "integrity": "sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==", + "dev": true, + "requires": {} + } + } + }, + "webpack-dev-middleware": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.0.2.tgz", + "integrity": "sha512-iOddiJzPcQC6lwOIu60vscbGWth8PCRcWRCwoQcTQf9RMoOWBHg5EyzpGdtSmGMrSPd5vHEfFXmVErQEmkRngQ==", + "dev": true, + "requires": { + "colorette": "^2.0.10", + "memfs": "^3.4.12", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + } + }, + "webpack-dev-server": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.13.2.tgz", + "integrity": "sha512-5i6TrGBRxG4vnfDpB6qSQGfnB6skGBXNL5/542w2uRGLimX6qeE5BQMLrzIC3JYV/xlGOv+s+hTleI9AZKUQNw==", + "dev": true, + "requires": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.13.0" + }, + "dependencies": { + "webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "requires": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + } + }, + "ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "dev": true, + "requires": {} + } + } + }, + "webpack-ext-reloader": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/webpack-ext-reloader/-/webpack-ext-reloader-1.1.9.tgz", + "integrity": "sha512-6AVXGrjcVHKtIQn4yGGghJpiIV2h9F7hNKLsh1oP8m+d6H3QLF3jTNu3vNdKu/8Lab3J/gwb7Bm7tjZLa+DS6g==", + "dev": true, + "requires": { + "@types/webextension-polyfill": "^0.8.2", + "@types/webpack": "^5.28.0", + "@types/webpack-sources": "^3.2.0", + "clean-webpack-plugin": "^4.0.0", + "colors": "^1.4.0", + "cross-env": "^7.0.3", + "lodash": "^4.17.21", + "minimist": "^1.2.5", + "useragent": "^2.3.0", + "webextension-polyfill": "^0.8.0", + "webpack-sources": "^3.2.3", + "ws": "^8.4.2" + } + }, + "webpack-merge": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", + "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "webpack-subresource-integrity": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", + "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", + "dev": true, + "requires": { + "typed-assert": "^1.0.8" + } + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "requires": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true + }, + "whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "requires": { + "iconv-lite": "0.6.3" + } + }, + "whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, + "whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "requires": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "requires": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "which-typed-array": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.1" + } + }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "wildcard": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", + "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", + "dev": true + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + } + } + }, + "wrap-ansi-cjs": { + "version": "npm:wrap-ansi@7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "requires": {} + }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dev": true, + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true + }, + "xxhashjs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", + "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "dev": true, + "requires": { + "cuint": "^0.2.2" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yaml": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz", + "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "dependencies": { + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + } + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + }, + "zip-a-folder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zip-a-folder/-/zip-a-folder-1.1.5.tgz", + "integrity": "sha512-w6I4mvWc6D0Q4pdzCSFbQih/ezYBdjwGZVbWRRFMOYcOdtE9TONZ7YtXCPnHj4XJQmXQxTOWcRGnPYxRn+d0mw==", + "dev": true, + "requires": { + "archiver": "^5.3.1" + } + }, + "zip-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz", + "integrity": "sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==", + "dev": true, + "requires": { + "archiver-utils": "^2.1.0", + "compress-commons": "^4.1.0", + "readable-stream": "^3.6.0" + } + }, + "zone.js": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.13.0.tgz", + "integrity": "sha512-7m3hNNyswsdoDobCkYNAy5WiUulkMd3+fWaGT9ij6iq3Zr/IwJo4RMCYPSDjT+r7tnPErmY9sZpKhWQ8S5k6XQ==", + "requires": { + "tslib": "^2.3.0" + } + } + } +} diff --git a/desktop/angular/package.json b/desktop/angular/package.json new file mode 100644 index 00000000..c1c058c8 --- /dev/null +++ b/desktop/angular/package.json @@ -0,0 +1,104 @@ +{ + "name": "portmaster", + "version": "0.8.3", + "scripts": { + "ng": "ng", + "start": "npm install && npm run build-libs:dev && ng serve --proxy-config ./proxy.json", + "build-libs": "NODE_ENV=production ng build --configuration production @safing/ui && NODE_ENV=production ng build --configuration production @safing/portmaster-api", + "build-libs:dev": "ng build --configuration development @safing/ui && ng build --configuration development @safing/portmaster-api", + "serve": "npm run build-libs:dev && ng serve --proxy-config ./proxy.json", + "build:dev": "npm run build-libs:dev && ng build", + "test": "ng test", + "lint": "ng lint", + "e2e": "ng e2e", + "zip-dist": "node pack.js", + "chrome-extension": "NODE_ENV=production ng build --configuration production portmaster-chrome-extension", + "chrome-extension:dev": "ng build --configuration development portmaster-chrome-extension --watch", + "build": "npm run build-libs && NODE_ENV=production ng build --configuration production --base-href /ui/modules/portmaster/", + "build-tauri": "npm run build-libs && NODE_ENV=production ng build --configuration production tauri-builtin", + "serve-tauri-builtin": "ng serve tauri-builtin --port 4100", + "serve-app": "ng serve --port 4200 --proxy-config ./proxy.json", + "tauri-dev": "npm install && run-s build-libs:dev && run-p serve-app serve-tauri-builtin" + }, + "private": true, + "dependencies": { + "@angular/animations": "^16.0.1", + "@angular/cdk": "^16.0.1", + "@angular/common": "^16.0.1", + "@angular/compiler": "^16.0.1", + "@angular/core": "^16.0.1", + "@angular/forms": "^16.0.1", + "@angular/localize": "^16.0.1", + "@angular/platform-browser": "^16.0.1", + "@angular/platform-browser-dynamic": "^16.0.1", + "@angular/router": "^16.0.1", + "@fortawesome/angular-fontawesome": "^0.13.0", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-brands-svg-icons": "^6.4.0", + "@fortawesome/free-regular-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@tauri-apps/api": "^2.0.0-beta.3", + "@tauri-apps/plugin-cli": "^2.0.0-beta.1", + "@tauri-apps/plugin-clipboard-manager": "^2.0.0-alpha.4", + "@tauri-apps/plugin-dialog": "^2.0.0-alpha.4", + "@tauri-apps/plugin-notification": "^2.0.0-alpha.4", + "@tauri-apps/plugin-os": "^2.0.0-alpha.5", + "@tauri-apps/plugin-shell": "^2.0.0-alpha.4", + "autoprefixer": "^10.4.14", + "d3": "^7.8.4", + "data-urls": "^5.0.0", + "emoji-toolkit": "^7.0.1", + "fuse.js": "^6.6.2", + "ng-zorro-antd": "^16.1.0", + "ngx-markdown": "^16.0.0", + "postcss": "^8.4.23", + "prismjs": "^1.29.0", + "psl": "^1.9.0", + "rxjs": "~7.8.1", + "topojson-client": "^3.1.0", + "topojson-simplify": "^3.0.3", + "tslib": "^2.5.0", + "whatwg-encoding": "^3.1.1", + "zone.js": "^0.13.0" + }, + "devDependencies": { + "@angular-builders/custom-webpack": "^16.0.0-beta.1", + "@angular-devkit/build-angular": "^16.0.1", + "@angular-eslint/builder": "16.0.1", + "@angular-eslint/eslint-plugin": "16.0.1", + "@angular-eslint/eslint-plugin-template": "16.0.1", + "@angular-eslint/schematics": "16.0.1", + "@angular-eslint/template-parser": "16.0.1", + "@angular/cli": "^16.0.1", + "@angular/compiler-cli": "^16.0.1", + "@fullhuman/postcss-purgecss": "^5.0.0", + "@types/chrome": "^0.0.236", + "@types/d3": "^7.4.0", + "@types/data-urls": "^3.0.4", + "@types/jasmine": "^4.3.1", + "@types/jasminewd2": "~2.0.10", + "@types/node": "^20.1.5", + "@types/psl": "^1.1.0", + "@types/topojson-client": "^3.1.1", + "@types/topojson-simplify": "^3.0.1", + "@types/whatwg-encoding": "^2.0.3", + "@typescript-eslint/eslint-plugin": "^5.59.6", + "@typescript-eslint/parser": "^5.59.6", + "eslint": "^8.40.0", + "jasmine-core": "^5.0.0", + "jasmine-spec-reporter": "^7.0.0", + "js-yaml-loader": "^1.2.2", + "ng-packagr": "^16.0.1", + "npm-run-all": "^4.1.5", + "postcss-import": "^15.1.0", + "postcss-loader": "^7.3.0", + "postcss-scss": "^4.0.6", + "protractor": "~7.0.0", + "tailwindcss": "^3.3.2", + "ts-node": "^10.9.1", + "typescript": "4.9", + "webpack-bundle-analyzer": "^4.8.0", + "webpack-ext-reloader": "^1.1.9", + "zip-a-folder": "^1.1.5" + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/karma.conf.js b/desktop/angular/projects/portmaster-chrome-extension/karma.conf.js new file mode 100644 index 00000000..eaac9a49 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/karma.conf.js @@ -0,0 +1,44 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution with `random: false` + // or set a specific seed with `seed: 4321` + }, + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join(__dirname, '../../coverage/portmaster-chrome-extension'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' } + ] + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true + }); +}; diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/app-routing.module.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/app-routing.module.ts new file mode 100644 index 00000000..73a41af9 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/app-routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ExtDomainListComponent } from './domain-list'; +import { IntroComponent } from './welcome/intro.component'; + +const routes: Routes = [ + { path: '', pathMatch: 'full', component: ExtDomainListComponent }, + { path: 'authorize', pathMatch: 'prefix', component: IntroComponent } +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.html b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.html new file mode 100644 index 00000000..d1b1eb54 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.html @@ -0,0 +1,3 @@ + + + diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.scss b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.scss new file mode 100644 index 00000000..b25b9d22 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.scss @@ -0,0 +1,3 @@ +:host { + @apply bg-background text-white flex flex-col w-96 h-96; +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.ts new file mode 100644 index 00000000..e8d9a987 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.ts @@ -0,0 +1,54 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, OnInit } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { MetaAPI, MyProfileResponse, retryPipeline } from '@safing/portmaster-api'; +import { catchError, filter, throwError } from 'rxjs'; + + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], +}) +export class AppComponent implements OnInit { + isAuthorizeView = false; + + constructor( + private metaapi: MetaAPI, + private router: Router, + ) { } + + profile: MyProfileResponse | null = null; + + ngOnInit(): void { + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd) + ) + .subscribe(event => { + if (event instanceof NavigationEnd) { + this.isAuthorizeView = event.url.includes("/authorize") + } + }) + + this.metaapi.myProfile() + .pipe( + catchError(err => { + if (err instanceof HttpErrorResponse && err.status === 403) { + this.router.navigate(['/authorize']) + } + + return throwError(() => err) + }), + retryPipeline() + ) + .subscribe({ + next: profile => { + this.profile = profile; + + console.log(this.profile); + } + }) + } + +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/app.module.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.module.ts new file mode 100644 index 00000000..93c418a3 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/app.module.ts @@ -0,0 +1,39 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { PortmasterAPIModule } from '@safing/portmaster-api'; +import { TabModule } from '@safing/ui'; +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +import { ExtDomainListComponent } from './domain-list'; +import { ExtHeaderComponent } from './header'; +import { AuthIntercepter as AuthInterceptor } from './interceptor'; +import { WelcomeModule } from './welcome'; + + +@NgModule({ + declarations: [ + AppComponent, + ExtDomainListComponent, + ExtHeaderComponent, + ], + imports: [ + BrowserModule, + AppRoutingModule, + HttpClientModule, + PortmasterAPIModule.forRoot(), + TabModule, + WelcomeModule, + OverlayModule, + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, + multi: true, + useClass: AuthInterceptor, + } + ], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.html b/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.html new file mode 100644 index 00000000..44bc3f02 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.html @@ -0,0 +1,27 @@ +
    +
  • +
    + + + + + + + + + + {{ req.domain }} + +
    + + + {{ req.lastConn.extra_data?.reason?.Msg }} + +
  • +
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.ts new file mode 100644 index 00000000..b0e78d45 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.ts @@ -0,0 +1,129 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from "@angular/core"; +import { Netquery, NetqueryConnection } from "@safing/portmaster-api"; +import { ListRequests, NotifyRequests } from "../../background/commands"; +import { Request } from '../../background/tab-tracker'; + +interface DomainRequests { + domain: string; + requests: Request[]; + latestIsBlocked: boolean; + lastConn?: NetqueryConnection; +} + +@Component({ + selector: 'ext-domain-list', + templateUrl: './domain-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + @apply flex flex-grow flex-col overflow-auto; + } + ` + ] +}) +export class ExtDomainListComponent implements OnInit { + requests: DomainRequests[] = []; + + constructor( + private netquery: Netquery, + private cdr: ChangeDetectorRef + ) { } + + ngOnInit() { + // setup listening for requests sent from our background script + const self = this; + chrome.runtime.onMessage.addListener((msg: NotifyRequests) => { + if (typeof msg !== 'object') { + console.error('Received invalid message from background script') + + return; + } + + console.log(`DEBUG: received command ${msg.type} from background script`) + + switch (msg.type) { + case 'notifyRequests': + self.updateRequests(msg.requests); + break; + + default: + console.error('Received unknown command from background script') + } + }) + + this.loadRequests(); + } + + updateRequests(req: Request[]) { + let m = new Map(); + + this.requests.forEach(obj => { + obj.requests = []; + m.set(obj.domain, obj); + }); + + req.forEach(r => { + let obj = m.get(r.domain); + if (!obj) { + obj = { + domain: r.domain, + requests: [], + latestIsBlocked: false + } + m.set(r.domain, obj) + } + + obj.requests.push(r); + }) + + this.requests = []; + Array.from(m.keys()).sort() + .map(key => m.get(key)!) + .forEach(obj => { + this.requests.push(obj) + + this.netquery.query({ + query: { + domain: obj.domain, + }, + orderBy: [ + { + field: 'started', + desc: true, + } + ], + page: 0, + pageSize: 1, + }) + .subscribe(result => { + if (!result[0]) { + return; + } + + obj.latestIsBlocked = !result[0].allowed; + obj.lastConn = result[0] as NetqueryConnection; + }) + }) + + this.cdr.detectChanges(); + } + + private loadRequests() { + const cmd: ListRequests = { + type: 'listRequests', + tabId: 'current' + } + + const self = this; + chrome.runtime.sendMessage(cmd, (response: any) => { + if (Array.isArray(response)) { + self.updateRequests(response) + + return; + } + + console.error(response); + }) + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/index.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/index.ts new file mode 100644 index 00000000..c0b4110c --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/index.ts @@ -0,0 +1 @@ +export * from './domain-list.component'; diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.html b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.html new file mode 100644 index 00000000..e61fb0e7 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.html @@ -0,0 +1,22 @@ +
+ + + + + + + + + + + + Secure + +
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss new file mode 100644 index 00000000..5c958f4e --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss @@ -0,0 +1,29 @@ +svg { + transform: scale(0.95); + + path { + top: 0px; + left: 0px; + transform-origin: center center; + } + + .shield-one { + transform: scale(.62); + } + + .shield-two { + animation-delay: -1.2s; + opacity: .6; + transform: scale(.8); + } + + .shield-three { + animation-delay: -2.5s; + opacity: .4; + transform: scale(1); + } + + .shield-ok { + transform: scale(.62); + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts new file mode 100644 index 00000000..3712f321 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts @@ -0,0 +1,9 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +@Component({ + selector: 'ext-header', + templateUrl: './header.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./header.component.scss'] +}) +export class ExtHeaderComponent { } diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts new file mode 100644 index 00000000..be62c26c --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts @@ -0,0 +1 @@ +export * from './header.component'; diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts new file mode 100644 index 00000000..a33e1d04 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts @@ -0,0 +1,45 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { BehaviorSubject, filter, Observable, switchMap } from "rxjs"; + + +@Injectable() +export class AuthIntercepter implements HttpInterceptor { + /** Used to delay requests until we loaded the access token from the extension storage. */ + private loaded$ = new BehaviorSubject(false); + + /** Holds the access token required to talk to the Portmaster API. */ + private token: string | null = null; + + constructor() { + // make sure we use the new access token once we get one. + chrome.storage.onChanged.addListener(changes => { + this.token = changes['key'].newValue || null; + }) + + // try to read the current access token from the extension storage. + chrome.storage.local.get('key', obj => { + this.token = obj.key || null; + console.log("got token", this.token) + this.loaded$.next(true); + }) + + chrome.runtime.sendMessage({ type: 'listRequests', tabId: 'current' }, (response: any) => { + console.log(response); + }) + } + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + return this.loaded$.pipe( + filter(loaded => loaded), + switchMap(() => { + if (!!this.token) { + req = req.clone({ + headers: req.headers.set("Authorization", "Bearer " + this.token) + }) + } + return next.handle(req) + }) + ) + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts new file mode 100644 index 00000000..159a5ea5 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; + + + +@Injectable({ + providedIn: 'root' +}) +export class RequestInterceptorService { + /** Used to emit when a new URL was requested */ + private onUrlRequested$ = new Subject(); + + /** Used to emit when a URL has likely been blocked by the portmaster */ + private onUrlBlocked$ = new Subject(); + + /** Emits when a new URL was requested */ + get onUrlRequested() { + return this.onUrlRequested$.asObservable(); + } + + /** Emits when a new URL was likely blocked by the portmaster */ + get onUrlBlocked() { + return this.onUrlBlocked$.asObservable(); + } + + constructor() { + this.registerCallbacks() + } + + private registerCallbacks() { + const filter = { + urls: [ + "http://*/*", + "https://*/*", + ] + }; + + chrome.webRequest.onBeforeRequest.addListener(details => this.onUrlRequested$.next(details), filter) + chrome.webRequest.onErrorOccurred.addListener(details => { + if (details.error !== "net::ERR_ADDRESS_UNREACHABLE") { + // we don't care about errors other than UNREACHABLE because that's error caused + // by the portmaster. + return; + } + + this.onUrlBlocked$.next(details); + }, filter) + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts new file mode 100644 index 00000000..a695cb02 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts @@ -0,0 +1,2 @@ +export * from './welcome.module'; + diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html new file mode 100644 index 00000000..017da699 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html @@ -0,0 +1,48 @@ +
+ +

+ + + + + + + + + + + Welcome to the + + Portmaster Browser Extension + + +

+
+
+ + + This extension adds direct support for Portmaster to your Browser. For that, it needs to get access to the + Portmaster on your system. For security reasons, you first need to authorize the Browser Extension to talk to the + Portmaster. + + + + + +

Waiting for Authorization

+ + Please open the Portmaster and approve the authorization request. + +
+ + +
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts new file mode 100644 index 00000000..45d6b3d9 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts @@ -0,0 +1,44 @@ +import { Component } from "@angular/core"; +import { Router } from "@angular/router"; +import { MetaAPI } from "@safing/portmaster-api"; +import { Subject, takeUntil } from "rxjs"; + +@Component({ + templateUrl: './intro.component.html', + styles: [ + ` + :host { + @apply flex flex-col h-full; + } + ` + ] +}) +export class IntroComponent { + private cancelRequest$ = new Subject(); + + state: 'authorizing' | 'failed' | '' = ''; + + constructor( + private meta: MetaAPI, + private router: Router, + ) { } + + authorizeExtension() { + // cancel any pending request + this.cancelRequest$.next(); + + this.state = 'authorizing'; + this.meta.requestApplicationAccess("Portmaster Browser Extension") + .pipe(takeUntil(this.cancelRequest$)) + .subscribe({ + next: token => { + chrome.storage.local.set(token); + console.log(token); + this.router.navigate(['/']) + }, + error: err => { + this.state = 'failed'; + } + }) + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts new file mode 100644 index 00000000..a0de7207 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts @@ -0,0 +1,19 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { OverlayStepperModule } from "@safing/ui"; +import { IntroComponent } from "./intro.component"; + +@NgModule({ + imports: [ + CommonModule, + OverlayStepperModule, + ], + declarations: [ + IntroComponent, + ], + exports: [ + IntroComponent, + ] +}) +export class WelcomeModule { } + diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/assets/.gitkeep b/desktop/angular/projects/portmaster-chrome-extension/src/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png b/desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png new file mode 100644 index 00000000..063948f1 Binary files /dev/null and b/desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png differ diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background.ts new file mode 100644 index 00000000..e6a0986c --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/background.ts @@ -0,0 +1,133 @@ +import { debounceTime, Subject } from "rxjs"; +import { CallRequest, ListRequests, NotifyRequests } from "./background/commands"; +import { Request, TabTracker } from "./background/tab-tracker"; +import { getCurrentTab } from "./background/tab-utils"; + +export class BackgroundService { + /** a lookup map for tab trackers by tab-id */ + private trackers = new Map(); + + /** used to signal the pop-up that new requests arrived */ + private notifyRequests = new Subject(); + + constructor() { + // register a navigation-completed listener. This is fired when the user switches to a new website + // by entering it in the browser address bar. + chrome.webNavigation.onCompleted.addListener((details) => { + console.log("event: webNavigation.onCompleted", details); + }) + + // request event listeners for new requests and errors that occured for them. + // We only care about http and https here. + const filter = { + urls: [ + 'http://*/*', + 'https://*/*' + ] + } + chrome.webRequest.onBeforeRequest.addListener(details => this.handleOnBeforeRequest(details), filter) + chrome.webRequest.onErrorOccurred.addListener(details => this.handleOnErrorOccured(details), filter) + + // make sure we can communicate with the extension popup + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => this.handleMessage(msg, sender, sendResponse)) + + // set-up signalling of new requests to the pop-up + this.notifyRequests + .pipe(debounceTime(500)) + .subscribe(async () => { + const currentTab = await getCurrentTab(); + if (!!currentTab && !!currentTab.id) { + const msg: NotifyRequests = { + type: 'notifyRequests', + requests: this.mustGetTab({ tabId: currentTab.id }).allRequests() + } + + chrome.runtime.sendMessage(msg) + } + }) + } + + /** Callback for messages sent by the popup */ + private handleMessage(msg: CallRequest, sender: chrome.runtime.MessageSender, sendResponse: (msg: any) => void) { + console.log(`DEBUG: got message from ${sender.origin} (tab=${sender.tab?.id})`) + + if (typeof msg !== 'object') { + console.error(`Received invalid message from popup`, msg) + + return; + } + + let response: Promise; + switch (msg.type) { + case 'listRequests': + response = this.handleListRequests(msg) + break; + + default: + response = Promise.reject("unknown command") + } + + response + .then(res => { + console.log(`DEBUG: sending response for command ${msg.type}`, res) + sendResponse(res); + }) + .catch(err => { + console.error(`Failed to handle command ${msg.type}`, err) + sendResponse({ + type: 'error', + details: err + }); + }) + } + + /** Returns a list of all observed requests based on the filter in msg. */ + private async handleListRequests(msg: ListRequests): Promise { + if (msg.tabId === 'current') { + const currentID = (await getCurrentTab()).id + if (!currentID) { + return []; + } + + msg.tabId = currentID; + } + + const tracker = this.mustGetTab({ tabId: msg.tabId as number }) + + if (!!msg.domain) { + return tracker.forDomain(msg.domain) + } + + return tracker.allRequests() + } + + /** Callback for chrome.webRequest.onBeforeRequest */ + private handleOnBeforeRequest(details: chrome.webRequest.WebRequestDetails) { + this.mustGetTab(details).trackRequest(details) + + this.notifyRequests.next(); + } + + /** Callback for chrome.webRequest.onErrorOccured */ + private handleOnErrorOccured(details: chrome.webRequest.WebResponseErrorDetails) { + this.mustGetTab(details).trackError(details); + + this.notifyRequests.next(); + } + + /** Returns the tab-tracker for tabId. Creates a new tracker if none exists. */ + private mustGetTab({ tabId }: { tabId: number }): TabTracker { + let tracker = this.trackers.get(tabId); + if (!tracker) { + tracker = new TabTracker(tabId) + this.trackers.set(tabId, tracker) + } + + return tracker; + } +} + +/** start the background service once we got successfully installed. */ +chrome.runtime.onInstalled.addListener(() => { + new BackgroundService() +}); diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts new file mode 100644 index 00000000..6bfdcd88 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts @@ -0,0 +1,14 @@ +import { Request } from "./tab-tracker"; + +export interface ListRequests { + type: 'listRequests'; + domain?: string; + tabId: number | 'current'; +} + +export interface NotifyRequests { + type: 'notifyRequests', + requests: Request[]; +} + +export type CallRequest = ListRequests; diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts new file mode 100644 index 00000000..f5a0628e --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts @@ -0,0 +1,126 @@ +import { deepClone } from "@safing/portmaster-api"; + +export interface Request { + /** The ID assigned by the browser */ + id: string; + + /** The domain this request was for */ + domain: string; + + /** The timestamp in milliseconds since epoch at which the request was initiated */ + time: number; + + /** Whether or not this request errored with net::ERR_ADDRESS_UNREACHABLE */ + isUnreachable: boolean; +} + +/** + * TabTracker tracks requests to domains made by a single browser tab. + */ +export class TabTracker { + /** A list of requests observed for this tab order by time they have been initiated */ + private requests: Request[] = []; + + /** A lookup map for requests to specific domains */ + private byDomain = new Map(); + + /** A lookup map for requests by the chrome request ID */ + private byRequestId = new Map; + + constructor(public readonly tabId: number) { } + + /** Returns an array of all requests observed in this tab. */ + allRequests(): Request[] { + return deepClone(this.requests) + } + + /** Returns a list of requests that have been observed for domain */ + forDomain(domain: string): Request[] { + if (!domain.endsWith(".")) { + domain += "." + } + + return this.byDomain.get(domain) || []; + } + + /** Call to add the details of a web-request to this tab-tracker */ + trackRequest(details: chrome.webRequest.WebRequestDetails) { + // If this is the wrong tab ID ignore the request details + if (details.tabId !== this.tabId) { + console.error(`TabTracker.trackRequest: called with wrong tab ID. Expected ${this.tabId} but got ${details.tabId}`) + + return; + } + + // if the type of the request is for the main_frame the user switched to a new website. + // In that case, we can wipe out all currently stored requests as the user will likely not + // care anymore. + if (details.type === "main_frame") { + this.clearState(); + } + + // get the domain of the request normalized to contain the trailing dot. + let domain = new URL(details.url).host; + if (!domain.endsWith(".")) { + domain += "." + } + + const req: Request = { + id: details.requestId, + domain: domain, + time: details.timeStamp, + isUnreachable: false, // we don't actually know that yet + } + + this.requests.push(req); + this.byRequestId.set(req.id, req) + + // Add the request to the by-domain lookup map + let byDomainRequests = this.byDomain.get(req.domain); + if (!byDomainRequests) { + byDomainRequests = []; + this.byDomain.set(req.domain, byDomainRequests) + } + byDomainRequests.push(req) + + console.log(`DEBUG: observed request ${req.id} to ${req.domain}`) + } + + /** Call to notify the tab-tracker of a request error */ + trackError(errorDetails: chrome.webRequest.WebResponseErrorDetails) { + // we only care about net::ERR_ADDRESS_UNREACHABLE here because that's how the + // Portmaster blocks the request. + + // TODO(ppacher): docs say we must not rely on that value so we should figure out a better + // way to detect if the error is caused by the Portmaster. + if (errorDetails.error !== "net::ERR_ADDRESS_UNREACHABLE") { + return; + } + + // the the previsouly observed request by the request ID. + const req = this.byRequestId.get(errorDetails.requestId) + if (!req) { + console.error("TabTracker.trackError: request has not been observed before") + + return + } + + // make sure the error details actually happend for the observed tab. + if (errorDetails.tabId !== this.tabId) { + console.error(`TabTracker.trackRequest: called with wrong tab ID. Expected ${this.tabId} but got ${errorDetails.tabId}`) + + return; + } + + // mark the request as unreachable. + req.isUnreachable = true; + console.log(`DEBUG: marked request ${req.id} to ${req.domain} as unreachable`) + } + + /** Clears the current state of the tab tracker */ + private clearState() { + this.requests = []; + this.byDomain = new Map(); + this.byRequestId = new Map(); + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts new file mode 100644 index 00000000..36635ca8 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts @@ -0,0 +1,9 @@ + +/** Queries and returns the currently active tab */ +export function getCurrentTab(): Promise { + return new Promise((resolve) => { + chrome.tabs.query({ active: true, lastFocusedWindow: true }, ([tab]) => { + resolve(tab); + }) + }) +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts new file mode 100644 index 00000000..ffe8aed7 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: false +}; diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts new file mode 100644 index 00000000..f56ff470 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts @@ -0,0 +1,16 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false +}; + +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico b/desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico new file mode 100644 index 00000000..997406ad Binary files /dev/null and b/desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico differ diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/index.html b/desktop/angular/projects/portmaster-chrome-extension/src/index.html new file mode 100644 index 00000000..afb08c65 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/index.html @@ -0,0 +1,13 @@ + + + + + PortmasterChromeExtension + + + + + + + + diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/main.ts b/desktop/angular/projects/portmaster-chrome-extension/src/main.ts new file mode 100644 index 00000000..c7b673cf --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/main.ts @@ -0,0 +1,12 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/manifest.json b/desktop/angular/projects/portmaster-chrome-extension/src/manifest.json new file mode 100644 index 00000000..db045a05 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Portmaster Browser Extension", + "version": "0.1", + "description": "Browser Extension for even better Portmaster integration", + "manifest_version": 2, + "permissions": [ + "activeTab", + "storage", + "webRequest", + "webNavigation", + "*://*/*" + ], + "browser_action": { + "default_popup": "index.html", + "default_icon": { + "128": "assets/icon_128.png" + } + }, + "background": { + "scripts": ["background.js"], + "persistent": true + } +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts b/desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts new file mode 100644 index 00000000..429bb9ef --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts @@ -0,0 +1,53 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes recent versions of Safari, Chrome (including + * Opera), Edge on the desktop, and iOS and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js'; // Included with Angular CLI. + + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/styles.scss b/desktop/angular/projects/portmaster-chrome-extension/src/styles.scss new file mode 100644 index 00000000..e41283cd --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/styles.scss @@ -0,0 +1,8 @@ +/* You can add global styles to this file, and also import other style files */ + +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; + + +@import '@angular/cdk/overlay-prebuilt'; diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/test.ts b/desktop/angular/projects/portmaster-chrome-extension/src/test.ts new file mode 100644 index 00000000..51bb0206 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/src/test.ts @@ -0,0 +1,14 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); diff --git a/desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json new file mode 100644 index 00000000..28c28154 --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json @@ -0,0 +1,18 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [ + "chrome" + ] + }, + "files": [ + "src/main.ts", + "src/polyfills.ts", + "src/background.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json new file mode 100644 index 00000000..b66a2f0b --- /dev/null +++ b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json @@ -0,0 +1,18 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "files": [ + "src/test.ts", + "src/polyfills.ts" + ], + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/desktop/angular/projects/safing/portmaster-api/README.md b/desktop/angular/projects/safing/portmaster-api/README.md new file mode 100644 index 00000000..fc4110d2 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/README.md @@ -0,0 +1,24 @@ +# PortmasterApi + +This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.0. + +## Code scaffolding + +Run `ng generate component component-name --project portmaster-api` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project portmaster-api`. +> Note: Don't forget to add `--project portmaster-api` or else it will be added to the default project in your `angular.json` file. + +## Build + +Run `ng build portmaster-api` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Publishing + +After building your library with `ng build portmaster-api`, go to the dist folder `cd dist/portmaster-api` and run `npm publish`. + +## Running unit tests + +Run `ng test portmaster-api` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/desktop/angular/projects/safing/portmaster-api/karma.conf.js b/desktop/angular/projects/safing/portmaster-api/karma.conf.js new file mode 100644 index 00000000..6f9bd935 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/karma.conf.js @@ -0,0 +1,44 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution with `random: false` + // or set a specific seed with `seed: 4321` + }, + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join(__dirname, '../../../coverage/safing/portmaster-api'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' } + ] + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true + }); +}; diff --git a/desktop/angular/projects/safing/portmaster-api/ng-package.json b/desktop/angular/projects/safing/portmaster-api/ng-package.json new file mode 100644 index 00000000..4ea94f9a --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist-lib/safing/portmaster-api", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/package-lock.json b/desktop/angular/projects/safing/portmaster-api/package-lock.json new file mode 100644 index 00000000..848065cc --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/package-lock.json @@ -0,0 +1,132 @@ +{ + "name": "@safing/portmaster-api", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@safing/portmaster-api", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/jasmine": "^4.0.3" + }, + "peerDependencies": { + "@angular/common": "^14.0.0", + "@angular/core": "^14.0.0" + } + }, + "node_modules/@angular/common": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz", + "integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^14.15.0 || >=16.10.0" + }, + "peerDependencies": { + "@angular/core": "14.0.5", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/core": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz", + "integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^14.15.0 || >=16.10.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.11.4" + } + }, + "node_modules/@types/jasmine": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz", + "integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==", + "dev": true + }, + "node_modules/rxjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz", + "integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, + "node_modules/zone.js": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz", + "integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + } + } + }, + "dependencies": { + "@angular/common": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz", + "integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==", + "peer": true, + "requires": { + "tslib": "^2.3.0" + } + }, + "@angular/core": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz", + "integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==", + "peer": true, + "requires": { + "tslib": "^2.3.0" + } + }, + "@types/jasmine": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz", + "integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==", + "dev": true + }, + "rxjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz", + "integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==", + "peer": true, + "requires": { + "tslib": "^2.1.0" + } + }, + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, + "zone.js": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz", + "integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==", + "peer": true, + "requires": { + "tslib": "^2.3.0" + } + } + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/package.json b/desktop/angular/projects/safing/portmaster-api/package.json new file mode 100644 index 00000000..98483319 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "@safing/portmaster-api", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^14.0.0", + "@angular/core": "^14.0.0" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/jasmine": "^4.0.3" + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts new file mode 100644 index 00000000..814b67ff --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts @@ -0,0 +1,262 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, finalize, map, mergeMap, share, take } from 'rxjs/operators'; +import { + AppProfile, + FlatConfigObject, + LayeredProfile, + TagDescription, + flattenProfileConfig, +} from './app-profile.types'; +import { + PORTMASTER_HTTP_API_ENDPOINT, + PortapiService, +} from './portapi.service'; +import { Process } from './portapi.types'; + +@Injectable({ + providedIn: 'root', +}) +export class AppProfileService { + private watchedProfiles = new Map>(); + + constructor( + private portapi: PortapiService, + private http: HttpClient, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string + ) { } + + /** + * Returns the database key of a profile. + * + * @param source The source of the profile. + * @param id The profile ID. + */ + getKey(source: string, id: string): string; + + /** + * Returns the database key of a profile + * + * @param p The app-profile itself.. + */ + getKey(p: AppProfile): string; + + getKey(idOrSourceOrProfile: string | AppProfile, id?: string): string { + if (typeof idOrSourceOrProfile === 'object') { + return this.getKey(idOrSourceOrProfile.Source, idOrSourceOrProfile.ID); + } + + let key = idOrSourceOrProfile; + + if (!!id) { + key = `core:profiles/${idOrSourceOrProfile}/${id}`; + } + + return key; + } + + /** + * Load an application profile. + * + * @param sourceAndId The full profile ID including source + */ + getAppProfile(sourceAndId: string): Observable; + + /** + * Load an application profile. + * + * @param source The source of the profile + * @param id The ID of the profile + */ + getAppProfile(source: string, id: string): Observable; + + getAppProfile( + sourceOrSourceAndID: string, + id?: string + ): Observable { + let source = sourceOrSourceAndID; + if (id !== undefined) { + source += '/' + id; + } + const key = `core:profiles/${source}`; + + if (this.watchedProfiles.has(key)) { + return this.watchedProfiles.get(key)!.pipe(take(1)); + } + + return this.getAppProfileFromKey(key); + } + + setProfileIcon( + content: string | ArrayBuffer, + mimeType: string + ): Observable<{ filename: string }> { + return this.http.post<{ filename: string }>( + `${this.httpAPI}/v1/profile/icon`, + content, + { + headers: new HttpHeaders({ + 'Content-Type': mimeType, + }), + } + ); + } + + /** + * Loads an application profile by it's database key. + * + * @param key The key of the application profile. + */ + getAppProfileFromKey(key: string): Observable { + return this.portapi.get(key); + } + + /** + * Loads the global-configuration profile. + */ + globalConfig(): Observable { + return this.getAppProfile('special', 'global-config').pipe( + map((profile) => flattenProfileConfig(profile.Config)) + ); + } + + /** Returns all possible process tags. */ + tagDescriptions(): Observable { + return this.http + .get<{ Tags: TagDescription[] }>(`${this.httpAPI}/v1/process/tags`) + .pipe(map((result) => result.Tags)); + } + + /** + * Watches an application profile for changes. + * + * @param source The source of the profile + * @param id The ID of the profile + */ + watchAppProfile(sourceAndId: string): Observable; + /** + * Watches an application profile for changes. + * + * @param source The source of the profile + * @param id The ID of the profile + */ + watchAppProfile(source: string, id: string): Observable; + + watchAppProfile(sourceAndId: string, id?: string): Observable { + let key = ''; + + if (id === undefined) { + key = sourceAndId; + if (!key.startsWith('core:profiles/')) { + key = `core:profiles/${key}`; + } + } else { + key = `core:profiles/${sourceAndId}/${id}`; + } + + if (this.watchedProfiles.has(key)) { + return this.watchedProfiles.get(key)!; + } + + const stream = this.portapi.get(key).pipe( + mergeMap(() => this.portapi.watch(key)), + finalize(() => { + console.log( + 'watchAppProfile: removing cached profile stream for ' + key + ); + this.watchedProfiles.delete(key); + }), + share({ + connector: () => new BehaviorSubject(null), + resetOnRefCountZero: true, + }), + filter((profile) => profile !== null) + ) as Observable; + + this.watchedProfiles.set(key, stream); + + return stream; + } + + /** @deprecated use saveProfile instead */ + saveLocalProfile(profile: AppProfile): Observable { + return this.saveProfile(profile); + } + + /** + * Save an application profile. + * + * @param profile The profile to save + */ + saveProfile(profile: AppProfile): Observable { + profile.LastEdited = Math.floor(new Date().getTime() / 1000); + return this.portapi.update( + `core:profiles/${profile.Source}/${profile.ID}`, + profile + ); + } + + /** + * Watch all application profiles + */ + watchProfiles(): Observable { + return this.portapi.watchAll('core:profiles/'); + } + + watchLayeredProfile(source: string, id: string): Observable; + + /** + * Watches the layered runtime profile for a given application + * profile. + * + * @param profile The app profile + */ + watchLayeredProfile(profile: AppProfile): Observable; + + watchLayeredProfile( + profileOrSource: string | AppProfile, + id?: string + ): Observable { + if (typeof profileOrSource == 'object') { + id = profileOrSource.ID; + profileOrSource = profileOrSource.Source; + } + + const key = `runtime:layeredProfile/${profileOrSource}/${id}`; + return this.portapi.watch(key); + } + + /** + * Loads the layered runtime profile for a given application + * profile. + * + * @param profile The app profile + */ + getLayeredProfile(profile: AppProfile): Observable { + const key = `runtime:layeredProfile/${profile.Source}/${profile.ID}`; + return this.portapi.get(key); + } + + /** + * Delete an application profile. + * + * @param profile The profile to delete + */ + deleteProfile(profile: AppProfile): Observable { + return this.portapi.delete(`core:profiles/${profile.Source}/${profile.ID}`); + } + + getProcessesByProfile(profileOrId: AppProfile | string): Observable { + if (typeof profileOrId === 'object') { + profileOrId = profileOrId.Source + "/" + profileOrId.ID + } + + return this.http.get(`${this.httpAPI}/v1/process/list/by-profile/${profileOrId}`) + } + + getProcessByPid(pid: number): Observable { + return this.http.get(`${this.httpAPI}/v1/process/group-leader/${pid}`) + } +} + diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts new file mode 100644 index 00000000..986d62ff --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts @@ -0,0 +1,215 @@ +import { BaseSetting, OptionValueType, SettingValueType } from './config.types'; +import { SecurityLevel } from './core.types'; +import { Record } from './portapi.types'; + +export interface ConfigMap { + [key: string]: ConfigObject; +} + +export type ConfigObject = OptionValueType | ConfigMap; + +export interface FlatConfigObject { + [key: string]: OptionValueType; +} + + +export interface LayeredProfile extends Record { + // LayerIDs is a list of all profiles that are used + // by this layered profile. Profiles are evaluated in + // order. + LayerIDs: string[]; + + // The current revision counter of the layered profile. + RevisionCounter: number; +} + +export enum FingerprintType { + Tag = 'tag', + Cmdline = 'cmdline', + Env = 'env', + Path = 'path', +} + +export enum FingerpringOperation { + Equal = 'equals', + Prefix = 'prefix', + Regex = 'regex', +} + +export interface Fingerprint { + Type: FingerprintType; + Key: string; + Operation: FingerpringOperation; + Value: string; +} + +export interface TagDescription { + ID: string; + Name: string; + Description: string; +} + +export interface Icon { + Type: 'database' | 'path' | 'api'; + Source: '' | 'user' | 'import' | 'core' | 'ui'; + Value: string; +} + +export interface AppProfile extends Record { + ID: string; + LinkedPath: string; // deprecated + PresentationPath: string; + Fingerprints: Fingerprint[]; + Created: number; + LastEdited: number; + Config?: ConfigMap; + Description: string; + Warning: string; + WarningLastUpdated: string; + Homepage: string; + Icons: Icon[]; + Name: string; + Internal: boolean; + SecurityLevel: SecurityLevel; + Source: 'local'; +} + +// flattenProfileConfig returns a flat version of a nested ConfigMap where each property +// can be used as the database key for the associated setting. +export function flattenProfileConfig( + p?: ConfigMap, + prefix = '' +): FlatConfigObject { + if (p === null || p === undefined) { + return {} + } + + let result: FlatConfigObject = {}; + + Object.keys(p).forEach((key) => { + const childPrefix = prefix === '' ? key : `${prefix}/${key}`; + + const prop = p[key]; + + if (isConfigMap(prop)) { + const flattened = flattenProfileConfig(prop, childPrefix); + result = mergeObjects(result, flattened); + return; + } + + result[childPrefix] = prop; + }); + + return result; +} + +/** + * Returns the current value (or null) of a setting stored in a config + * map by path. + * + * @param obj The ConfigMap object + * @param path The path of the setting separated by foward slashes. + */ +export function getAppSetting( + obj: ConfigMap | null | undefined, + path: string +): T | null { + if (obj === null || obj === undefined) { + return null + } + + const parts = path.split('/'); + + let iter = obj; + for (let idx = 0; idx < parts.length; idx++) { + const propName = parts[idx]; + + if (iter[propName] === undefined) { + return null; + } + + const value = iter[propName]; + if (idx === parts.length - 1) { + return value as T; + } + + if (!isConfigMap(value)) { + return null; + } + + iter = value; + } + return null; +} + +export function getActualValue>( + s: S +): SettingValueType { + if (s.Value !== undefined) { + return s.Value; + } + if (s.GlobalDefault !== undefined) { + return s.GlobalDefault; + } + return s.DefaultValue; +} + +/** + * Sets the value of a settings inside the nested config object. + * + * @param obj THe config object + * @param path The path of the setting + * @param value The new value to set. + */ +export function setAppSetting(obj: ConfigObject, path: string, value: any) { + const parts = path.split('/'); + if (typeof obj !== 'object' || Array.isArray(obj)) { + return; + } + + let iter = obj; + for (let idx = 0; idx < parts.length; idx++) { + const propName = parts[idx]; + + if (idx === parts.length - 1) { + if (value === undefined) { + delete iter[propName]; + } else { + iter[propName] = value; + } + return; + } + + if (iter[propName] === undefined) { + iter[propName] = {}; + } + + iter = iter[propName] as ConfigMap; + } +} + +/** Typeguard to ensure v is a ConfigMap */ +function isConfigMap(v: any): v is ConfigMap { + return typeof v === 'object' && !Array.isArray(v); +} + +/** + * Returns a new flat-config object that contains values from both + * parameters. + * + * @param a The first config object + * @param b The second config object + */ +function mergeObjects( + a: FlatConfigObject, + b: FlatConfigObject +): FlatConfigObject { + var res: FlatConfigObject = {}; + Object.keys(a).forEach((key) => { + res[key] = a[key]; + }); + Object.keys(b).forEach((key) => { + res[key] = b[key]; + }); + return res; +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts new file mode 100644 index 00000000..58daeb28 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts @@ -0,0 +1,128 @@ +import { Injectable, TrackByFunction } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, share, toArray } from 'rxjs/operators'; +import { BaseSetting, BoolSetting, OptionType, Setting, SettingValueType } from './config.types'; +import { PortapiService } from './portapi.service'; + + +@Injectable({ + providedIn: 'root' +}) +export class ConfigService { + networkRatingEnabled$: Observable; + + /** + * A {@link TrackByFunction} for tracking settings. + */ + static trackBy: TrackByFunction = (_: number, obj: Setting) => obj.Name; + readonly trackBy = ConfigService.trackBy; + + /** configPrefix is the database key prefix for the config db */ + readonly configPrefix = "config:"; + + constructor(private portapi: PortapiService) { + this.networkRatingEnabled$ = this.watch("core/enableNetworkRating") + .pipe( + share({ connector: () => new BehaviorSubject(false) }), + ) + } + + /** + * Loads a configuration setting from the database. + * + * @param key The key of the configuration setting. + */ + get(key: string): Observable { + return this.portapi.get(this.configPrefix + key); + } + + /** + * Returns all configuration settings that match query. Note that in + * contrast to {@link PortAPI} settings values are collected into + * an array before being emitted. This allows simple usage in *ngFor + * and friends. + * + * @param query The query used to search for configuration settings. + */ + query(query: string): Observable { + return this.portapi.query(this.configPrefix + query) + .pipe( + map(setting => setting.data), + toArray() + ); + } + + /** + * Save a setting. + * + * @param s The setting to save. Note that the new value should already be set to {@property Value}. + */ + save(s: Setting): Observable; + + /** + * Save a setting. + * + * @param key The key of the configuration setting + * @param value The new value of the setting. + */ + save(key: string, value: any): Observable; + + // save is overloaded, see above. + save(s: Setting | string, v?: any): Observable { + if (typeof s === 'string') { + return this.portapi.update(this.configPrefix + s, { + Key: s, + Value: v, + }); + } + return this.portapi.update(this.configPrefix + s.Key, s); + } + + /** + * Watch a configuration setting. + * + * @param key The key of the setting to watch. + */ + watch(key: string): Observable> { + return this.portapi.qsub, any>>(this.configPrefix + key) + .pipe( + filter(value => value.key === this.configPrefix + key), // qsub does a query so filter for our key. + map(value => value.data), + map(value => value.Value !== undefined ? value.Value : value.DefaultValue), + distinctUntilChanged(), + ) + } + + /** + * Tests if a value is valid for a given option. + * + * @param spec The option specification (as returned by get()). + * @param value The value that should be tested. + */ + validate(spec: S, value: SettingValueType) { + if (!spec.ValidationRegex) { + return; + } + + const re = new RegExp(spec.ValidationRegex); + + switch (spec.OptType) { + case OptionType.Int: + case OptionType.Bool: + // todo(ppacher): do we validate that? + return + case OptionType.String: + if (!re.test(value as string)) { + throw new Error(`${value} does not match ${spec.ValidationRegex}`) + } + return; + case OptionType.StringArray: + (value as string[]).forEach(v => { + if (!re.test(v as string)) { + throw new Error(`${value} does not match ${spec.ValidationRegex}`) + } + }); + return + } + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts new file mode 100644 index 00000000..99fe5d82 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts @@ -0,0 +1,348 @@ +import { FeatureID } from './features'; +import { Record } from './portapi.types'; +import { deepClone } from './utils'; + +/** + * ExpertiseLevel defines all available expertise levels. + */ +export enum ExpertiseLevel { + User = 'user', + Expert = 'expert', + Developer = 'developer', +} + +export enum ExpertiseLevelNumber { + user = 0, + expert = 1, + developer = 2 +} + +export function getExpertiseLevelNumber(lvl: ExpertiseLevel): ExpertiseLevelNumber { + switch (lvl) { + case ExpertiseLevel.User: + return ExpertiseLevelNumber.user; + case ExpertiseLevel.Expert: + return ExpertiseLevelNumber.expert; + case ExpertiseLevel.Developer: + return ExpertiseLevelNumber.developer + } +} + +/** + * OptionType defines the type of an option as stored in + * the backend. Note that ExternalOptionHint may be used + * to request a different visual representation and edit + * menu on a per-option basis. + */ +export enum OptionType { + String = 1, + StringArray = 2, + Int = 3, + Bool = 4, +} + +/** + * Converts an option type to it's string representation. + * + * @param opt The option type to convert + */ +export function optionTypeName(opt: OptionType): string { + switch (opt) { + case OptionType.String: + return 'string'; + case OptionType.StringArray: + return '[]string'; + case OptionType.Int: + return 'int' + case OptionType.Bool: + return 'bool' + } +} + +/** The actual type an option value can be */ +export type OptionValueType = string | string[] | number | boolean; + +/** Type-guard for string option types */ +export function isStringType(opt: OptionType, vt: OptionValueType): vt is string { + return opt === OptionType.String; +} + +/** Type-guard for string-array option types */ +export function isStringArrayType(opt: OptionType, vt: OptionValueType): vt is string[] { + return opt === OptionType.StringArray; +} + +/** Type-guard for number option types */ +export function isNumberType(opt: OptionType, vt: OptionValueType): vt is number { + return opt === OptionType.Int; +} + +/** Type-guard for boolean option types */ +export function isBooleanType(opt: OptionType, vt: OptionValueType): vt is boolean { + return opt === OptionType.Bool; +} + +/** + * ReleaseLevel defines the available release and maturity + * levels. + */ +export enum ReleaseLevel { + Stable = 0, + Beta = 1, + Experimental = 2, +} + +export function releaseLevelFromName(name: 'stable' | 'beta' | 'experimental'): ReleaseLevel { + switch (name) { + case 'stable': + return ReleaseLevel.Stable; + case 'beta': + return ReleaseLevel.Beta; + case 'experimental': + return ReleaseLevel.Experimental; + } +} + +/** + * releaseLevelName returns a string representation of the + * release level. + * + * @args level The release level to convert. + */ +export function releaseLevelName(level: ReleaseLevel): string { + switch (level) { + case ReleaseLevel.Stable: + return 'stable' + case ReleaseLevel.Beta: + return 'beta' + case ReleaseLevel.Experimental: + return 'experimental' + } +} + +/** + * ExternalOptionHint tells the UI to use a different visual + * representation and edit menu that the options value would + * imply. + */ +export enum ExternalOptionHint { + SecurityLevel = 'security level', + EndpointList = 'endpoint list', + FilterList = 'filter list', + OneOf = 'one-of', + OrderedList = 'ordered' +} + +/** A list of well-known option annotation keys. */ +export enum WellKnown { + DisplayHint = "safing/portbase:ui:display-hint", + Order = "safing/portbase:ui:order", + Unit = "safing/portbase:ui:unit", + Category = "safing/portbase:ui:category", + Subsystem = "safing/portbase:module:subsystem", + Stackable = "safing/portbase:options:stackable", + QuickSetting = "safing/portbase:ui:quick-setting", + Requires = "safing/portbase:config:requires", + RestartPending = "safing/portbase:options:restart-pending", + EndpointListVerdictNames = "safing/portmaster:ui:endpoint-list:verdict-names", + RequiresFeatureID = "safing/portmaster:ui:config:requires-feature", + RequiresUIReload = "safing/portmaster:ui:requires-reload", +} + +/** + * Annotations describes the annoations object of a configuration + * setting. Well-known annotations are stricktly typed. + */ +export interface Annotations { + // Well known option annoations and their + // types. + [WellKnown.DisplayHint]?: ExternalOptionHint; + [WellKnown.Order]?: number; + [WellKnown.Unit]?: string; + [WellKnown.Category]?: string; + [WellKnown.Subsystem]?: string; + [WellKnown.Stackable]?: true; + [WellKnown.QuickSetting]?: QuickSetting | QuickSetting[] | CountrySelectionQuickSetting | CountrySelectionQuickSetting[]; + [WellKnown.Requires]?: ValueRequirement | ValueRequirement[]; + [WellKnown.RequiresFeatureID]?: FeatureID | FeatureID[]; + [WellKnown.RequiresUIReload]?: unknown, + // Any thing else... + [key: string]: any; +} + +export interface PossilbeValue { + /** Name is the name of the value and should be displayed */ + Name: string; + /** Description may hold an additional description of the value */ + Description: string; + /** Value is the actual value expected by the portmaster */ + Value: T; +} + +export interface QuickSetting { + // Name is the name of the quick setting. + Name: string; + // Value is the value that the quick-setting configures. It must match + // the expected value type of the annotated option. + Value: T; + // Action defines the action of the quick setting. + Action: 'replace' | 'merge-top' | 'merge-bottom'; +} + +export interface CountrySelectionQuickSetting extends QuickSetting { + // Filename of the flag to be used. + // In most cases this will be the 2-letter country code, but there are also special flags. + FlagID: string; +} + +export interface ValueRequirement { + // Key is the configuration key of the required setting. + Key: string; + // Value is the required value of the linked setting. + Value: any; +} + +/** + * BaseSetting describes the general shape of a portbase config setting. + */ +export interface BaseSetting extends Record { + // Value is the value of a setting. + Value?: T; + // DefaultValue is the default value of a setting. + DefaultValue: T; + // Description is a short description. + Description?: string; + // ExpertiseLevel defines the required expertise level for + // this setting to show up. + ExpertiseLevel: ExpertiseLevelNumber; + // Help may contain a longer help text for this option. + Help?: string; + // Key is the database key. + Key: string; + // Name is the name of the option. + Name: string; + // OptType is the option's basic type. + OptType: O; + // Annotations holds option specific annotations. + Annotations: Annotations; + // ReleaseLevel defines the release level of the feature + // or settings changed by this option. + ReleaseLevel: ReleaseLevel; + // RequiresRestart may be set to true if the service requires + // a restart after this option has been changed. + RequiresRestart?: boolean; + // ValidateRegex defines the regex used to validate this option. + // The regex is used in Golang but is expected to be valid in + // JavaScript as well. + ValidationRegex?: string; + PossibleValues?: PossilbeValue[]; + + // GlobalDefault holds the global default value and is used in the app settings + // This property is NOT defined inside the portmaster! + GlobalDefault?: T; +} + +export type IntSetting = BaseSetting; +export type StringSetting = BaseSetting; +export type StringArraySetting = BaseSetting; +export type BoolSetting = BaseSetting; + +/** + * Apply a quick setting to a value. + * + * @param current The current value of the setting. + * @param qs The quick setting to apply. + */ +export function applyQuickSetting(current: V | null, qs: QuickSetting): V | null { + if (qs.Action === 'replace' || !qs.Action) { + return deepClone(qs.Value); + } + + if ((!Array.isArray(current) && current !== null) || !Array.isArray(qs.Value)) { + console.warn(`Tried to ${qs.Action} quick-setting on non-array type`); + return current; + } + + const clone = deepClone(current); + let missing: any[] = []; + + qs.Value.forEach(val => { + if (clone.includes(val)) { + return + } + missing.push(val); + }); + + if (qs.Action === 'merge-bottom') { + return clone.concat(missing) as V; + } + + return missing.concat(clone) as V; +} + +/** + * Parses the ValidationRegex of a setting and returns a list + * of supported values. + * + * @param s The setting to extract support values from. + */ +export function parseSupportedValues(s: S): SettingValueType[] { + if (!s.ValidationRegex) { + return []; + } + + const values = s.ValidationRegex.match(/\w+/gmi); + const result: SettingValueType[] = []; + + let converter: (s: string) => any; + + switch (s.OptType) { + case OptionType.Bool: + converter = s => s === 'true'; + break; + case OptionType.Int: + converter = s => +s; + break; + case OptionType.String: + case OptionType.StringArray: + converter = s => s + break + } + + values?.forEach(val => { + result.push(converter(val)) + }); + + return result; +} + +/** + * isDefaultValue checks if value is the settings default value. + * It supports all available settings type and fallsback to use + * JSON encoded string comparision (JS JSON.stringify is stable). + */ +export function isDefaultValue(value: T | undefined | null, defaultValue: T): boolean { + if (value === undefined) { + return true; + } + + const isObject = typeof value === 'object'; + const isDefault = isObject + ? JSON.stringify(value) === JSON.stringify(defaultValue) + : value === defaultValue; + + return isDefault; +} + +/** + * SettingValueType is used to infer the type of a settings from it's default value. + * Use like this: + * + * validate(spec: S, value SettingValueType) { ... } + */ +export type SettingValueType = S extends { DefaultValue: infer T } ? T : any; + +export type Setting = IntSetting + | StringSetting + | StringArraySetting + | BoolSetting; diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts new file mode 100644 index 00000000..5e5e1417 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts @@ -0,0 +1,34 @@ +import { TrackByFunction } from '@angular/core'; + +export enum SecurityLevel { + Off = 0, + Normal = 1, + High = 2, + Extreme = 4, +} + +export enum RiskLevel { + Off = 'off', + Auto = 'auto', + Low = 'low', + Medium = 'medium', + High = 'high' +} + +/** Interface capturing any object that has an ID member. */ +export interface Identifyable { + ID: string | number; +} + +/** A TrackByFunction for all Identifyable objects. */ +export const trackById: TrackByFunction = (_: number, obj: Identifyable) => { + return obj.ID; +} + +export function getEnumKey(enumLike: any, value: string | number): string { + if (typeof value === 'string') { + return value.toLowerCase() + } + + return (enumLike[value] as string).toLowerCase() +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts new file mode 100644 index 00000000..f0617943 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts @@ -0,0 +1,54 @@ +import { HttpClient } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service'; + +@Injectable({ + providedIn: 'root', +}) +export class DebugAPI { + constructor( + private http: HttpClient, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string, + ) { } + + ping(): Observable { + return this.http.get(`${this.httpAPI}/v1/ping`, { + responseType: 'text' + }) + } + + getStack(): Observable { + return this.http.get(`${this.httpAPI}/v1/debug/stack`, { + responseType: 'text' + }) + } + + getDebugInfo(style = 'github'): Observable { + return this.http.get(`${this.httpAPI}/v1/debug/info`, { + params: { + style, + }, + responseType: 'text', + }) + } + + getCoreDebugInfo(style = 'github'): Observable { + return this.http.get(`${this.httpAPI}/v1/debug/core`, { + params: { + style, + }, + responseType: 'text', + }) + } + + getProfileDebugInfo(source: string, id: string, style = 'github'): Observable { + return this.http.get(`${this.httpAPI}/v1/debug/network`, { + params: { + profile: `${source}/${id}`, + style, + }, + responseType: 'text', + }) + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/features.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/features.ts new file mode 100644 index 00000000..658f1c1b --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/features.ts @@ -0,0 +1,8 @@ +export enum FeatureID { + None = "", + SPN = "spn", + PrioritySupport = "support", + History = "history", + Bandwidth = "bw-vis", + VPNCompat = "vpn-compat", +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts new file mode 100644 index 00000000..009848f4 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts @@ -0,0 +1,106 @@ +import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; +import { Inject, Injectable, Optional } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service'; + +export interface MetaEndpointParameter { + Method: string; + Field: string; + Value: string; + Description: string; +} + +export interface MetaEndpoint { + Path: string; + MimeType: string; + Read: number; + Write: number; + Name: string; + Description: string; + Parameters: MetaEndpointParameter[]; +} + +export interface AuthPermission { + Read: number; + Write: number; + ReadRole: string; + WriteRole: string; +} + +export interface MyProfileResponse { + profile: string; + source: string; + name: string; +} + +export interface AuthKeyResponse { + key: string; + validUntil: string; +} + +@Injectable() +export class MetaAPI { + constructor( + private http: HttpClient, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) @Optional() private httpEndpoint: string = 'http://localhost:817/api', + ) { } + + listEndpoints(): Observable { + return this.http.get(`${this.httpEndpoint}/v1/endpoints`) + } + + permissions(): Observable { + return this.http.get(`${this.httpEndpoint}/v1/auth/permissions`) + } + + myProfile(): Observable { + return this.http.get(`${this.httpEndpoint}/v1/app/profile`) + } + + requestApplicationAccess(appName: string, read: 'user' | 'admin' = 'user', write: 'user' | 'admin' = 'user'): Observable { + let params = new HttpParams() + .set("app-name", appName) + .set("read", read) + .set("write", write) + + return this.http.get(`${this.httpEndpoint}/v1/app/auth`, { params }) + } + + login(bearer: string): Observable; + login(username: string, password: string): Observable; + login(usernameOrBearer: string, password?: string): Observable { + let login: Observable; + + if (!!password) { + login = this.http.get(`${this.httpEndpoint}/v1/auth/basic`, { + headers: { + 'Authorization': `Basic ${btoa(usernameOrBearer + ":" + password)}` + } + }) + } else { + login = this.http.get(`${this.httpEndpoint}/v1/auth/bearer`, { + headers: { + 'Authorization': `Bearer ${usernameOrBearer}` + } + }) + } + + return login.pipe( + map(() => true), + catchError(err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 401) { + return of(false); + } + } + + return throwError(() => err) + }) + ) + } + + logout(): Observable { + return this.http.get(`${this.httpEndpoint}/v1/auth/reset`); + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/module.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/module.ts new file mode 100644 index 00000000..0ed13363 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/module.ts @@ -0,0 +1,55 @@ +import { ModuleWithProviders, NgModule } from "@angular/core"; +import { AppProfileService } from "./app-profile.service"; +import { ConfigService } from "./config.service"; +import { DebugAPI } from "./debug-api.service"; +import { MetaAPI } from "./meta-api.service"; +import { Netquery } from "./netquery.service"; +import { PortapiService, PORTMASTER_HTTP_API_ENDPOINT, PORTMASTER_WS_API_ENDPOINT } from "./portapi.service"; +import { SPNService } from "./spn.service"; +import { WebsocketService } from "./websocket.service"; + +export interface ModuleConfig { + httpAPI?: string; + websocketAPI?: string; +} + +@NgModule({}) +export class PortmasterAPIModule { + + /** + * Configures a module with additional providers. + * + * @param cfg The module configuration defining the Portmaster HTTP and Websocket API endpoints. + */ + static forRoot(cfg: ModuleConfig = {}): ModuleWithProviders { + if (cfg.httpAPI === undefined) { + cfg.httpAPI = `http://${window.location.host}/api`; + } + if (cfg.websocketAPI === undefined) { + cfg.websocketAPI = `ws://${window.location.host}/api/database/v1`; + } + + return { + ngModule: PortmasterAPIModule, + providers: [ + PortapiService, + WebsocketService, + MetaAPI, + ConfigService, + AppProfileService, + DebugAPI, + Netquery, + SPNService, + { + provide: PORTMASTER_HTTP_API_ENDPOINT, + useValue: cfg.httpAPI, + }, + { + provide: PORTMASTER_WS_API_ENDPOINT, + useValue: cfg.websocketAPI + } + ] + } + } + +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts new file mode 100644 index 00000000..c0b1ec88 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts @@ -0,0 +1,543 @@ +import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http"; +import { Inject, Injectable } from "@angular/core"; +import { Observable, forkJoin, of } from "rxjs"; +import { catchError, map, mergeMap } from "rxjs/operators"; +import { AppProfileService } from "./app-profile.service"; +import { AppProfile } from "./app-profile.types"; +import { DNSContext, IPScope, Reason, TLSContext, TunnelContext, Verdict } from "./network.types"; +import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from "./portapi.service"; +import { Container } from "postcss"; + +export interface FieldSelect { + field: string; +} + +export interface FieldAsSelect { + $field: { + field: string; + as: string; + } +} + +export interface Count { + $count: { + field: string; + distinct?: boolean; + as?: string; + } +} + +export interface Sum { + $sum: { + condition: Condition; + as: string; + distinct?: boolean; + } | { + field: string; + as: string; + distinct?: boolean; + } +} + +export interface Min { + $min: { + condition: Condition; + as: string; + distinct?: boolean; + } | { + field: string; + as: string; + distinct?: boolean; + } +} + +export interface Distinct { + $distinct: string; +} + +export type Select = FieldSelect | FieldAsSelect | Count | Distinct | Sum | Min; + +export interface Equal { + $eq: any; +} + +export interface NotEqual { + $ne: any; +} + +export interface Like { + $like: string; +} + +export interface In { + $in: any[]; +} + +export interface NotIn { + $notin: string[]; +} + +export interface Greater { + $gt: number; +} + +export interface GreaterOrEqual { + $ge: number; +} + +export interface Less { + $lt: number; +} + +export interface LessOrEqual { + $le: number; +} + +export type Matcher = Equal | NotEqual | Like | In | NotIn | Greater | GreaterOrEqual | Less | LessOrEqual; + +export interface OrderBy { + field: string; + desc?: boolean; +} + +export interface Condition { + [key: string]: string | Matcher | (string | Matcher)[]; +} + +export interface TextSearch { + fields: string[]; + value: string; +} + +export enum Database { + Live = "main", + History = "history" +} + +export interface Query { + select?: string | Select | (Select | string)[]; + query?: Condition; + orderBy?: string | OrderBy | (OrderBy | string)[]; + textSearch?: TextSearch; + groupBy?: string[]; + pageSize?: number; + page?: number; + databases?: Database[]; +} + +export interface NetqueryConnection { + id: string; + allowed: boolean | null; + profile: string; + path: string; + type: 'dns' | 'ip'; + external: boolean; + ip_version: number; + ip_protocol: number; + local_ip: string; + local_port: number; + remote_ip: string; + remote_port: number; + domain: string; + country: string; + asn: number; + as_owner: string; + latitude: number; + longitude: number; + scope: IPScope; + verdict: Verdict; + started: string; + ended: string; + tunneled: boolean; + encrypted: boolean; + internal: boolean; + direction: 'inbound' | 'outbound'; + profile_revision: number; + exit_node?: string; + extra_data?: { + pid?: number; + processCreatedAt?: number; + cname?: string[]; + blockedByLists?: string[]; + blockedEntities?: string[]; + reason?: Reason; + tunnel?: TunnelContext; + dns?: DNSContext; + tls?: TLSContext; + }; + + profile_name: string; + active: boolean; + bytes_received: number; + bytes_sent: number; +} + +export interface ChartResult { + timestamp: number; + value: number; + countBlocked: number; +} + +export interface QueryResult extends Partial { + [key: string]: any; +} + +export interface Identities { + exit_node: string; + count: number; +} + +export interface IProfileStats { + ID: string; + Name: string; + + size: number; + empty: boolean; + identities: Identities[]; + countAllowed: number; + countUnpermitted: number; + countAliveConnections: number; + bytes_sent: number; + bytes_received: number; +} + +type BatchResponse = { + [key in keyof T]: QueryResult[] +} + +interface BatchRequest { + [key: string]: Query +} + +interface BandwidthBaseResult { + timestamp: number; + incoming: number; + outgoing: number; +} + +export type ConnKeys = keyof NetqueryConnection + +export type BandwidthChartResult = { + [key in K]: NetqueryConnection[K]; +} & BandwidthBaseResult + +export type ProfileBandwidthChartResult = BandwidthChartResult<'profile'>; + +export type ConnectionBandwidthChartResult = BandwidthChartResult<'id'>; + +@Injectable({ providedIn: 'root' }) +export class Netquery { + constructor( + private http: HttpClient, + private profileService: AppProfileService, + private portapi: PortapiService, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string, + ) { } + + query(query: Query, origin: string): Observable { + return this.http.post<{ results: QueryResult[] }>(`${this.httpAPI}/v1/netquery/query`, query, { + params: new HttpParams().set("origin", origin) + }) + .pipe(map(res => res.results || [])); + } + + batch(queries: T): Observable> { + return this.http.post>(`${this.httpAPI}/v1/netquery/query/batch`, queries) + } + + cleanProfileHistory(profileIDs: string | string[]): Observable> { + return this.http.post(`${this.httpAPI}/v1/netquery/history/clear`, + { + profileIDs: Array.isArray(profileIDs) ? profileIDs : [profileIDs] + }, + { + observe: 'response', + responseType: 'text', + reportProgress: false, + } + ) + } + + profileBandwidthChart(profile?: string[], interval?: number): Observable<{ [profile: string]: ProfileBandwidthChartResult[] }> { + const cond: Condition = {} + if (!!profile) { + cond['profile'] = profile + } + + return this.bandwidthChart(cond, ['profile'], interval) + .pipe( + map(results => { + const obj: { + [connId: string]: ProfileBandwidthChartResult[] + } = {}; + + results?.forEach(row => { + const arr = obj[row.profile] || [] + arr.push(row) + obj[row.profile] = arr + }) + + return obj + }) + ) + } + + bandwidthChart(query: Condition, groupBy?: K[], interval?: number): Observable[]> { + return this.http.post<{ results: BandwidthChartResult[] }>(`${this.httpAPI}/v1/netquery/charts/bandwidth`, { + interval, + groupBy, + query, + }) + .pipe( + map(response => response.results), + ) + } + + connectionBandwidthChart(connIds: string[], interval?: number): Observable<{ [connId: string]: ConnectionBandwidthChartResult[] }> { + const cond: Condition = {} + if (!!connIds) { + cond['id'] = connIds + } + + return this.bandwidthChart(cond, ['id'], interval) + .pipe( + map(results => { + const obj: { + [connId: string]: ConnectionBandwidthChartResult[] + } = {}; + + results?.forEach(row => { + const arr = obj[row.id] || [] + arr.push(row) + obj[row.id] = arr + }) + + return obj + }) + ) + } + + activeConnectionChart(cond: Condition, textSearch?: TextSearch): Observable { + return this.http.post<{ results: ChartResult[] }>(`${this.httpAPI}/v1/netquery/charts/connection-active`, { + query: cond, + textSearch, + }) + .pipe(map(res => { + const now = new Date(); + + let data: ChartResult[] = []; + + let lastPoint: ChartResult | null = { + timestamp: Math.floor(now.getTime() / 1000 - 600), + value: 0, + countBlocked: 0, + }; + res.results?.forEach(point => { + if (!!lastPoint && lastPoint.timestamp < (point.timestamp - 10)) { + for (let i = lastPoint.timestamp; i < point.timestamp; i += 10) { + data.push({ + timestamp: i, + value: 0, + countBlocked: 0, + }) + } + } + data.push(point); + lastPoint = point; + }) + + const lastPointTs = Math.round(now.getTime() / 1000); + if (!!lastPoint && lastPoint.timestamp < (lastPointTs - 20)) { + for (let i = lastPoint.timestamp; i < lastPointTs; i += 20) { + data.push({ + timestamp: i, + value: 0, + countBlocked: 0 + }) + } + } + + return data; + })); + } + + getActiveProfileIDs(): Observable { + return this.query({ + select: [ + 'profile', + ], + groupBy: [ + 'profile', + ], + }, 'get-active-profile-ids').pipe( + map(result => { + return result.map(res => res.profile!); + }) + ) + } + + getActiveProfiles(): Observable { + return this.getActiveProfileIDs() + .pipe( + mergeMap(profiles => forkJoin(profiles.map(pid => this.profileService.getAppProfile(pid)))) + ) + } + + getProfileStats(query?: Condition): Observable { + let profileCache = new Map(); + + return this.batch({ + verdicts: { + select: [ + 'profile', + 'verdict', + { $count: { field: '*', as: 'totalCount' } }, + ], + groupBy: [ + 'profile', + 'verdict', + ], + query: query, + }, + + conns: { + select: [ + 'profile', + { $count: { field: '*', as: 'totalCount' } }, + { $count: { field: 'ended', as: 'countEnded' } }, + { $sum: { field: 'bytes_sent', as: 'bytes_sent' } }, + { $sum: { field: 'bytes_received', as: 'bytes_received' } }, + ], + groupBy: [ + 'profile', + ], + query: query, + }, + + identities: { + select: [ + 'profile', + 'exit_node', + { $count: { field: '*', as: 'totalCount' } } + ], + groupBy: [ + 'profile', + 'exit_node', + ], + query: { + ...query, + exit_node: { + $ne: "", + }, + }, + } + }).pipe( + map(result => { + let statsMap = new Map(); + + const getOrCreate = (id: string) => { + let stats = statsMap.get(id) || { + ID: id, + Name: 'Deleted', + countAliveConnections: 0, + countAllowed: 0, + countUnpermitted: 0, + empty: true, + identities: [], + size: 0, + bytes_received: 0, + bytes_sent: 0 + }; + + statsMap.set(id, stats); + return stats; + } + result.verdicts?.forEach(res => { + const stats = getOrCreate(res.profile!); + + switch (res.verdict) { + case Verdict.Accept: + case Verdict.RerouteToNs: + case Verdict.RerouteToTunnel: + case Verdict.Undeterminable: + stats.size += res.totalCount + stats.countAllowed += res.totalCount; + break; + + case Verdict.Block: + case Verdict.Drop: + case Verdict.Failed: + case Verdict.Undecided: + stats.size += res.totalCount + stats.countUnpermitted += res.totalCount; + break; + } + + stats.empty = stats.size == 0; + }) + + result.conns?.forEach(res => { + const stats = getOrCreate(res.profile!); + + stats.countAliveConnections = res.totalCount - res.countEnded; + stats.bytes_received += res.bytes_received!; + stats.bytes_sent += res.bytes_sent!; + }) + + result.identities?.forEach(res => { + const stats = getOrCreate(res.profile!); + + let ident = stats.identities.find(value => value.exit_node === res.exit_node) + if (!ident) { + ident = { + count: 0, + exit_node: res.exit_node!, + } + stats.identities.push(ident); + } + + ident.count += res.totalCount; + }) + + return Array.from(statsMap.values()) + }), + mergeMap(stats => { + return forkJoin(stats.map(p => { + if (profileCache.has(p.ID)) { + return of(profileCache.get(p.ID)!); + } + return this.profileService.getAppProfile(p.ID) + .pipe(catchError(err => { + return of(null) + })) + })) + .pipe( + map((profiles: (AppProfile | null)[]) => { + profileCache = new Map(); + + let lm = new Map(); + stats.forEach(stat => lm.set(stat.ID, stat)); + + profiles + .forEach(p => { + if (!p) { + return + } + + profileCache.set(`${p.Source}/${p.ID}`, p) + + let stat = lm.get(`${p.Source}/${p.ID}`) + if (!stat) { + return; + } + + stat.Name = p.Name + }) + + return Array.from(lm.values()) + }) + ) + }) + ) + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts new file mode 100644 index 00000000..6cdef998 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts @@ -0,0 +1,314 @@ +import { Record } from './portapi.types'; + +export enum Verdict { + Undecided = 0, + Undeterminable = 1, + Accept = 2, + Block = 3, + Drop = 4, + RerouteToNs = 5, + RerouteToTunnel = 6, + Failed = 7 +} + +export enum IPProtocol { + ICMP = 1, + IGMP = 2, + TCP = 6, + UDP = 17, + ICMPv6 = 58, + UDPLite = 136, + RAW = 255, // TODO(ppacher): what is RAW used for? +} + +export enum IPVersion { + V4 = 4, + V6 = 6, +} + +export enum IPScope { + Invalid = -1, + Undefined = 0, + HostLocal = 1, + LinkLocal = 2, + SiteLocal = 3, + Global = 4, + LocalMulticast = 5, + GlobalMulitcast = 6 +} + +let globalScopes = new Set([IPScope.GlobalMulitcast, IPScope.Global]) +let localScopes = new Set([IPScope.SiteLocal, IPScope.LinkLocal, IPScope.LocalMulticast]) + +// IsGlobalScope returns true if scope represents a globally +// routed destination. +export function IsGlobalScope(scope: IPScope): scope is IPScope.GlobalMulitcast | IPScope.Global { + return globalScopes.has(scope); +} + +// IsLocalScope returns true if scope represents a locally +// routed destination. +export function IsLANScope(scope: IPScope): scope is IPScope.SiteLocal | IPScope.LinkLocal | IPScope.LocalMulticast { + return localScopes.has(scope); +} + +// IsLocalhost returns true if scope represents localhost. +export function IsLocalhost(scope: IPScope): scope is IPScope.HostLocal { + return scope === IPScope.HostLocal; +} + +const deniedVerdicts = new Set([ + Verdict.Drop, + Verdict.Block, +]) +// IsDenied returns true if the verdict v represents a +// deny or block decision. +export function IsDenied(v: Verdict): boolean { + return deniedVerdicts.has(v); +} + +export interface CountryInfo { + Code: string; + Name: string; + Center: GeoCoordinates; + Continent: ContinentInfo; +} + +export interface ContinentInfo { + Code: string; + Region: string; + Name: string; +} + +export interface GeoCoordinates { + AccuracyRadius: number; + Latitude: number; + Longitude: number; +} + +export const UnknownLocation: GeoCoordinates = { + AccuracyRadius: 0, + Latitude: 0, + Longitude: 0 +} + +export interface IntelEntity { + // Protocol is the IP protocol used to connect/communicate + // the the described entity. + Protocol: IPProtocol; + // Port is the remote port number used. + Port: number; + // Domain is the domain name of the entity. This may either + // be the domain name used in the DNS request or the + // named returned from reverse PTR lookup. + Domain: string; + // CNAME is a list of CNAMEs that have been used + // to resolve this entity. + CNAME: string[] | null; + // IP is the IP address of the entity. + IP: string; + // IPScope holds the classification of the IP address. + IPScope: IPScope; + // Country holds the country of residence of the IP address. + Country: string; + // ASN holds the number of the autonoumous system that operates + // the IP. + ASN: number; + // ASOrg holds the AS owner name. + ASOrg: string; + // Coordinates contains the geographic coordinates of the entity. + Coordinates: GeoCoordinates | null; + // BlockedByLists holds a list of filter list IDs that + // would have blocked the entity. + BlockedByLists: string[] | null; + // BlockedEntities holds a list of entities that have been + // blocked by filter lists. Those entities can be ASNs, domains, + // CNAMEs, IPs or Countries. + BlockedEntities: string[] | null; + // ListOccurences maps the blocked entity (see BlockedEntities) + // to a list of filter-list IDs that contains it. + ListOccurences: { [key: string]: string[] } | null; +} + +export enum ScopeIdentifier { + IncomingHost = "IH", + IncomingLAN = "IL", + IncomingInternet = "II", + IncomingInvalid = "IX", + PeerHost = "PH", + PeerLAN = "PL", + PeerInternet = "PI", + PeerInvalid = "PX" +} + +export const ScopeTranslation: { [key: string]: string } = { + [ScopeIdentifier.IncomingHost]: "Device-Local Incoming", + [ScopeIdentifier.IncomingLAN]: "LAN Incoming", + [ScopeIdentifier.IncomingInternet]: "Internet Incoming", + [ScopeIdentifier.PeerHost]: "Device-Local Outgoing", + [ScopeIdentifier.PeerLAN]: "LAN Peer-to-Peer", + [ScopeIdentifier.PeerInternet]: "Internet Peer-to-Peer", + [ScopeIdentifier.IncomingInvalid]: "N/A", + [ScopeIdentifier.PeerInvalid]: "N/A", +} + +export interface ProcessContext { + BinaryPath: string; + ProcessName: string; + ProfileName: string; + PID: number; + Profile: string; + Source: string +} + +// Reason justifies the decision on a connection +// verdict. +export interface Reason { + // Msg holds a human readable message of the reason. + Msg: string; + // OptionKey, if available, holds the key of the + // configuration option that caused the verdict. + OptionKey: string; + // Profile holds the profile the option setting has + // been configured in. + Profile: string; + // Context may holds additional data about the reason. + Context: any; +} + +export enum ConnectionType { + Undefined = 0, + IPConnection = 1, + DNSRequest = 2 +} + +export function IsDNSRequest(t: ConnectionType): t is ConnectionType.DNSRequest { + return t === ConnectionType.DNSRequest; +} + +export function IsIPConnection(t: ConnectionType): t is ConnectionType.IPConnection { + return t === ConnectionType.IPConnection; +} + +export interface DNSContext { + Domain: string; + ServedFromCache: boolean; + RequestingNew: boolean; + IsBackup: boolean; + Filtered: boolean; + FilteredEntries: string[], // RR + Question: 'A' | 'AAAA' | 'MX' | 'TXT' | 'SOA' | 'SRV' | 'PTR' | 'NS' | string; + RCode: 'NOERROR' | 'SERVFAIL' | 'NXDOMAIN' | 'REFUSED' | string; + Modified: string; + Expires: string; +} + +export interface TunnelContext { + Path: TunnelNode[]; + PathCost: number; + RoutingAlg: 'default'; +} + +export interface GeoIPInfo { + IP: string; + Country: string; + ASN: number; + ASOwner: string; +} + +export interface TunnelNode { + ID: string; + Name: string; + IPv4?: GeoIPInfo; + IPv6?: GeoIPInfo; + +} + +export interface CertInfo { + Subject: string; + Issuer: string; + AlternateNames: string[]; + NotBefore: dateType; + NotAfter: dateType; +} + +export interface TLSContext { + Version: string; + VersionRaw: number; + SNI: string; + Chain: CertInfo[][]; +} + +export interface Connection extends Record { + // ID is a unique ID for the connection. + ID: string; + // Type defines the connection type. + Type: ConnectionType; + // TLS may holds additional data for the TLS + // session. + TLS: TLSContext | null; + // DNSContext holds additional data about the DNS request for + // this connection. + DNSContext: DNSContext | null; + // TunnelContext holds additional data about the SPN tunnel used for + // the connection. + TunnelContext: TunnelContext | null; + // Scope defines the scope of the connection. It's an somewhat + // weired field that may contain a ScopeIdentifier or a string. + // In case of a string it may eventually be interpreted as a + // domain name. + Scope: ScopeIdentifier | string; + // IPVersion is the version of the IP protocol used. + IPVersion: IPVersion; + // Inbound is true if the connection is incoming to + // hte local system. + Inbound: boolean; + // IPProtocol is the protocol used by the connection. + IPProtocol: IPProtocol; + // LocalIP is the local IP address that is involved into + // the connection. + LocalIP: string; + // LocalIPScope holds the classification of the local IP + // address; + LocalIPScope: IPScope; + // LocalPort is the local port that is involved into the + // connection. + LocalPort: number; + // Entity describes the remote entity that is part of the + // connection. + Entity: IntelEntity; + // Verdict defines the final verdict. + Verdict: Verdict; + // Reason is the reason justifying the verdict of the connection. + Reason: Reason; + // Started holds the number of seconds in UNIX epoch time at which + // the connection was initiated. + Started: number; + // End dholds the number of seconds in UNIX epoch time at which + // the connection was considered terminated. + Ended: number; + // Tunneled is set to true if the connection was tunneled through the + // SPN. + Tunneled: boolean; + // VerdictPermanent is set to true if the connection was marked and + // handed back to the operating system. + VerdictPermanent: boolean; + // Inspecting is set to true if the connection is being inspected. + Inspecting: boolean; + // Encrypted is set to true if the connection is estimated as being + // encrypted. Interpreting this field must be done with care! + Encrypted: boolean; + // Internal is set to true if this connection is done by the Portmaster + // or any associated helper processes/binaries itself. + Internal: boolean; + // ProcessContext holds additional information about the process + // that initated the connection. + ProcessContext: ProcessContext; + // ProfileRevisionCounter is used to track changes to the process + // profile. + ProfileRevisionCounter: number; +} + +export interface ReasonContext { + [key: string]: any; +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts new file mode 100644 index 00000000..4f243ecd --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts @@ -0,0 +1,1011 @@ +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { + Inject, + Injectable, + InjectionToken, + isDevMode, + NgZone, +} from '@angular/core'; +import { BehaviorSubject, Observable, Observer, of } from 'rxjs'; +import { + concatMap, + delay, + filter, + map, + retryWhen, + takeWhile, + tap, +} from 'rxjs/operators'; +import { WebSocketSubject } from 'rxjs/webSocket'; +import { + DataReply, + deserializeMessage, + DoneReply, + ImportResult, + InspectedActiveRequest, + isCancellable, + isDataReply, + ProfileImportResult, + Record, + ReplyMessage, + Requestable, + RequestMessage, + RequestType, + RetryableOpts, + retryPipeline, + serializeMessage, + WatchOpts, +} from './portapi.types'; +import { WebsocketService } from './websocket.service'; + +export const PORTMASTER_WS_API_ENDPOINT = new InjectionToken( + 'PortmasterWebsocketEndpoint' +); +export const PORTMASTER_HTTP_API_ENDPOINT = new InjectionToken( + 'PortmasterHttpApiEndpoint' +); + +export const RECONNECT_INTERVAL = 2000; + +let uniqueRequestId = 0; + +interface PendingMethod { + observer: Observer; + request: RequestMessage; +} + +@Injectable() +export class PortapiService { + /** The actual websocket connection, auto-(re)connects on subscription */ + private ws$: WebSocketSubject | null; + + /** used to emit changes to our "connection state" */ + private connectedSubject = new BehaviorSubject(false); + + /** A map to multiplex websocket messages to the actual observer/initator */ + private _streams$ = new Map>>(); + + /** Map to keep track of "still-to-send" requests when we are currently disconnected */ + private _pendingCalls$ = new Map(); + + /** Whether or not we are currently connected. */ + get connected$() { + return this.connectedSubject.asObservable(); + } + + /** @private DEBUGGING ONLY - keeps track of current requests and supports injecting messages */ + readonly activeRequests = new BehaviorSubject<{ + [key: string]: InspectedActiveRequest; + }>({}); + + constructor( + private websocketFactory: WebsocketService, + private ngZone: NgZone, + private http: HttpClient, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpEndpoint: string, + @Inject(PORTMASTER_WS_API_ENDPOINT) private wsEndpoint: string + ) { + // create a new websocket connection that will auto-connect + // on the first subscription and will automatically reconnect + // with consecutive subscribers. + this.ws$ = this.createWebsocket(); + + // no need to keep a reference to the subscription as we're not going + // to unsubscribe ... + this.ws$ + .pipe( + retryWhen((errors) => + errors.pipe( + // use concatMap to keep the errors in order and make sure + // they don't execute in parallel. + concatMap((e, i) => + of(e).pipe( + // We need to forward the error to all streams here because + // due to the retry feature the subscriber below won't see + // any error at all. + tap(() => { + this._streams$.forEach((observer) => observer.error(e)); + this._streams$.clear(); + }), + delay(1000) + ) + ) + ) + ) + ) + .subscribe( + (msg) => { + const observer = this._streams$.get(msg.id); + if (!observer) { + // it's expected that we receive done messages from time to time here + // as portmaster sends a "done" message after we "cancel" a subscription + // and we already remove the observer from _streams$ if the subscription + // is unsubscribed. So just hide that warning message for "done" + if (msg.type !== 'done') { + console.warn( + `Received message for unknown request id ${msg.id} (type=${msg.type})`, + msg + ); + } + return; + } + + // forward the message to the actual stream. + observer.next(msg as ReplyMessage); + }, + console.error, + () => { + // This should actually never happen but if, make sure + // we handle it ... + this._streams$.forEach((observer) => observer.complete()); + this._streams$.clear(); + } + ); + } + + /** Triggers a restart of the portmaster service */ + restartPortmaster(): Observable { + return this.http.post(`${this.httpEndpoint}/v1/core/restart`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); + } + + /** Triggers a shutdown of the portmaster service */ + shutdownPortmaster(): Observable { + return this.http.post(`${this.httpEndpoint}/v1/core/shutdown`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); + } + + /** Force the portmaster to check for updates */ + checkForUpdates(): Observable { + return this.http.post(`${this.httpEndpoint}/v1/updates/check`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + reportProgress: false, + }); + } + + /** Force a reload of the UI assets */ + reloadUI(): Observable { + return this.http.post(`${this.httpEndpoint}/v1/ui/reload`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); + } + + /** Clear DNS cache */ + clearDNSCache(): Observable { + return this.http.post(`${this.httpEndpoint}/v1/dns/clear`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); + } + + /** Reset the broadcast notifications state */ + resetBroadcastState(): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/broadcasts/reset-state`, + undefined, + { observe: 'response', responseType: 'arraybuffer' } + ); + } + + /** Re-initialize the SPN */ + reinitSPN(): Observable { + return this.http.post(`${this.httpEndpoint}/v1/spn/reinit`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); + } + + /** Cleans up the history database by applying history retention settings */ + cleanupHistory(): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/netquery/history/cleanup`, + undefined, + { observe: 'response', responseType: 'arraybuffer' } + ); + } + + /** Requests a resource from the portmaster as application/json and automatically parses the response body*/ + getResource(resource: string): Observable; + + /** Requests a resource from the portmaster as text */ + getResource(resource: string, type: string): Observable>; + + getResource( + resource: string, + type?: string + ): Observable | any> { + if (type !== undefined) { + return this.http.get(`${this.httpEndpoint}/v1/updates/get/${resource}`, { + headers: new HttpHeaders({ Accept: type }), + observe: 'response', + responseType: 'text', + }); + } + + return this.http.get( + `${this.httpEndpoint}/v1/updates/get/${resource}`, + { + headers: new HttpHeaders({ Accept: 'application/json' }), + responseType: 'json', + } + ); + } + + /** Export one or more settings, either from global settings or a specific profile */ + exportSettings( + keys: string[], + from: 'global' | string = 'global' + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/settings/export`, + { + from, + keys, + }, + { + headers: new HttpHeaders({ Accept: 'text/yaml' }), + responseType: 'text', + observe: 'body', + } + ); + } + + /** Validate a settings import for a given target */ + validateSettingsImport( + blob: string | Blob, + target: string | 'global' = 'global', + mimeType: string = 'text/yaml' + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/settings/import`, + { + target, + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: true, + } + ); + } + + /** Import settings into a given target */ + importSettings( + blob: string | Blob, + target: string | 'global' = 'global', + mimeType: string = 'text/yaml', + reset = false, + allowUnknown = false + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/settings/import`, + { + target, + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: false, + reset, + allowUnknown, + } + ); + } + + /** Import a profile */ + importProfile( + blob: string | Blob, + mimeType: string = 'text/yaml', + reset = false, + allowUnknown = false, + allowReplaceProfiles = false + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/profile/import`, + { + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: false, + reset, + allowUnknown, + allowReplaceProfiles, + } + ); + } + + /** Import a profile */ + validateProfileImport( + blob: string | Blob, + mimeType: string = 'text/yaml' + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/profile/import`, + { + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: true, + } + ); + } + + /** Export one or more settings, either from global settings or a specific profile */ + exportProfile(id: string): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/profile/export`, + { + id, + }, + { + headers: new HttpHeaders({ Accept: 'text/yaml' }), + responseType: 'text', + observe: 'body', + } + ); + } + + /** Merge multiple profiles into one primary profile. */ + mergeProfiles( + name: string, + primary: string, + secondaries: string[] + ): Observable { + return this.http + .post<{ new: string }>(`${this.httpEndpoint}/v1/profile/merge`, { + name: name, + to: primary, + from: secondaries, + }) + .pipe(map((response) => response.new)); + } + + /** + * Injects an event into a module to trigger certain backend + * behavior. + * + * @deprecated - Use the HTTP API instead. + * + * @param module The name of the module to inject + * @param kind The event kind to inject + */ + bridgeAPI(call: string, method: string): Observable { + return this.create(`api:${call}`, { + Method: method, + }).pipe(map(() => { })); + } + + /** + * Flushes all pending method calls that have been collected + * while we were not connected to the portmaster API. + */ + private _flushPendingMethods() { + const count = this._pendingCalls$.size; + try { + this._pendingCalls$.forEach((req, key) => { + // It's fine if we throw an error here! + this.ws$!.next(req.request); + this._streams$.set(req.request.id, req.observer); + this._pendingCalls$.delete(key); + }); + } catch (err) { + // we failed to send the pending calls because the + // websocket connection just broke. + console.error( + `Failed to flush pending calls, ${this._pendingCalls$.size} left: `, + err + ); + } + + console.log(`Successfully flushed all (${count}) pending calles`); + } + + /** + * Allows to inspect currently active requests. + */ + inspectActiveRequests(): { [key: string]: InspectedActiveRequest } { + return this.activeRequests.getValue(); + } + + /** + * Loads a database entry. The returned observable completes + * after the entry has been loaded. + * + * @param key The database key of the entry to load. + */ + get(key: string): Observable { + return this.request('get', { key }).pipe(map((res) => res.data)); + } + + /** + * Searches for multiple database entries at once. Each entry + * is streams via the returned observable. The observable is + * closed after the last entry has been published. + * + * @param query The query used to search the database. + */ + query(query: string): Observable> { + return this.request('query', { query }); + } + + /** + * Subscribes for updates on entries of the selected query. + * + * @param query The query use to subscribe. + */ + sub( + query: string, + opts: RetryableOpts = {} + ): Observable> { + return this.request('sub', { query }).pipe(retryPipeline(opts)); + } + + /** + * Subscribes for updates on entries of the selected query and + * ensures entries are stream once upon subscription. + * + * @param query The query use to subscribe. + * @todo(ppacher): check what a ok/done message mean here. + */ + qsub( + query: string, + opts?: RetryableOpts + ): Observable>; + qsub( + query: string, + opts: RetryableOpts, + _: { forwardDone: true } + ): Observable | DoneReply>; + qsub( + query: string, + opts: RetryableOpts = {}, + { forwardDone }: { forwardDone?: true } = {} + ): Observable> { + return this.request('qsub', { query }, { forwardDone }).pipe( + retryPipeline(opts) + ); + } + + /** + * Creates a new database entry. + * + * @warn create operations do not validate the type of data + * to be overwritten (for keys that does already exist). + * Use {@function insert} for more validation. + * + * @param key The database key for the entry. + * @param data The actual data for the entry. + */ + create(key: string, data: any): Observable { + data = this.stripMeta(data); + return this.request('create', { key, data }).pipe(map(() => { })); + } + + /** + * Updates an existing entry. + * + * @param key The database key for the entry + * @param data The actual, updated entry data. + */ + update(key: string, data: any): Observable { + data = this.stripMeta(data); + return this.request('update', { key, data }).pipe(map(() => { })); + } + + /** + * Creates a new database entry. + * + * @param key The database key for the entry. + * @param data The actual data for the entry. + * @todo(ppacher): check what's different to create(). + */ + insert(key: string, data: any): Observable { + data = this.stripMeta(data); + return this.request('insert', { key, data }).pipe(map(() => { })); + } + + /** + * Deletes an existing database entry. + * + * @param key The key of the database entry to delete. + */ + delete(key: string): Observable { + return this.request('delete', { key }).pipe(map(() => { })); + } + + /** + * Watch a database key for modifications. If the + * websocket connection is lost or an error is returned + * watch will automatically retry after retryDelay + * milliseconds. It stops retrying to watch key once + * maxRetries is exceeded. The returned observable completes + * when the watched key is deleted. + * + * @param key The database key to watch + * @param opts.retryDelay Number of milliseconds to wait + * between retrying the request. Defaults to 1000 + * @param opts.maxRetries Maximum number of tries before + * giving up. Defaults to Infinity + * @param opts.ingoreNew Whether or not `new` notifications + * will be ignored. Defaults to false + * @param opts.ignoreDelete Whether or not "delete" notification + * will be ignored (and replaced by null) + * @param forwardDone: Whether or not the "done" message should be forwarded + */ + watch(key: string, opts?: WatchOpts): Observable; + watch( + key: string, + opts?: WatchOpts & { ignoreDelete: true } + ): Observable; + watch( + key: string, + opts: WatchOpts, + _: { forwardDone: true } + ): Observable; + watch( + key: string, + opts: WatchOpts & { ignoreDelete: true }, + _: { forwardDone: true } + ): Observable; + watch( + key: string, + opts: WatchOpts = {}, + { forwardDone }: { forwardDone?: boolean } = {} + ): Observable { + return this.qsub(key, opts, { forwardDone } as any).pipe( + filter((reply) => reply.type !== 'done' || forwardDone === true), + filter((reply) => reply.type === 'done' || reply.key === key), + takeWhile((reply) => opts.ignoreDelete || reply.type !== 'del'), + filter((reply) => { + return !opts.ingoreNew || reply.type !== 'new'; + }), + map((reply) => { + if (reply.type === 'del') { + return null; + } + + if (reply.type === 'done') { + return reply; + } + return reply.data; + }) + ); + } + + watchAll( + query: string, + opts?: RetryableOpts + ): Observable { + return new Observable((observer) => { + let values: T[] = []; + let keys: string[] = []; + let doneReceived = false; + + const sub = this.request( + 'qsub', + { query }, + { forwardDone: true } + ).subscribe({ + next: (value) => { + if ((value as any).type === 'done') { + doneReceived = true; + observer.next(values); + return; + } + + if (!doneReceived) { + values.push(value.data); + keys.push(value.key); + return; + } + + const idx = keys.findIndex((k) => k === value.key); + switch (value.type) { + case 'new': + if (idx < 0) { + values.push(value.data); + keys.push(value.key); + } else { + /* + const existing = values[idx]._meta!; + const existingTs = existing.Modified || existing.Created; + const newTs = (value.data as Record)?._meta?.Modified || (value.data as Record)?._meta?.Created || 0; + + console.log(`Comparing ${newTs} against ${existingTs}`); + + if (newTs > existingTs) { + console.log(`New record is ${newTs - existingTs} seconds newer`); + values[idx] = value.data; + } else { + return; + } + */ + values[idx] = value.data; + } + break; + case 'del': + if (idx >= 0) { + keys.splice(idx, 1); + values.splice(idx, 1); + } + break; + case 'upd': + if (idx >= 0) { + values[idx] = value.data; + } + break; + } + + observer.next(values); + }, + error: (err) => { + observer.error(err); + }, + complete: () => { + observer.complete(); + }, + }); + + return () => { + sub.unsubscribe(); + }; + }).pipe(retryPipeline(opts)); + } + + /** + * Close the current websocket connection. A new subscription + * will _NOT_ trigger a reconnect. + */ + close() { + if (!this.ws$) { + return; + } + + this.ws$.complete(); + this.ws$ = null; + } + + request( + method: M, + attrs: Partial>, + { forwardDone }: { forwardDone?: boolean } = {} + ): Observable> { + return new Observable((observer) => { + const id = `${++uniqueRequestId}`; + if (!this.ws$) { + observer.error('No websocket connection'); + return; + } + + let shouldCancel = isCancellable(method); + let unsub: () => RequestMessage | null = () => { + if (shouldCancel) { + return { + id: id, + type: 'cancel', + }; + } + + return null; + }; + + const request: any = { + ...attrs, + id: id, + type: method, + }; + + let inspected: InspectedActiveRequest = { + type: method, + messagesReceived: 0, + observer: observer, + payload: request, + lastData: null, + lastKey: '', + }; + + if (isDevMode()) { + this.activeRequests.next({ + ...this.inspectActiveRequests(), + [id]: inspected, + }); + } + + let stream$: Observable> = this.multiplex( + request, + unsub + ); + if (isDevMode()) { + // in development mode we log all replys for the different + // methods. This also includes updates to subscriptions. + stream$ = stream$.pipe( + tap( + (msg) => { }, + //msg => console.log(`[portapi] reply for ${method} ${id}: `, msg), + (err) => console.error(`[portapi] error in ${method} ${id}: `, err) + ) + ); + } + + const subscription = stream$?.subscribe({ + next: (data) => { + inspected.messagesReceived++; + + // in all cases, an `error` message type + // terminates the data flow. + if (data.type === 'error') { + console.error(data.message, inspected); + shouldCancel = false; + + observer.error(data.message); + return; + } + + if ( + method === 'create' || + method === 'update' || + method === 'insert' || + method === 'delete' + ) { + // for data-manipulating methods success + // ends the stream. + if (data.type === 'success') { + observer.next(); + observer.complete(); + return; + } + } + + if (method === 'query' || method === 'sub' || method === 'qsub') { + if (data.type === 'warning') { + console.warn(data.message); + return; + } + + // query based methods send `done` once all + // results are sent at least once. + if (data.type === 'done') { + if (method === 'query') { + // done ends the query but does not end sub or qsub + shouldCancel = false; + observer.complete(); + return; + } + + if (!!forwardDone) { + // A done message in qsub does not actually represent + // a DataReply but we still want to forward that. + observer.next(data as any); + } + return; + } + } + + if (!isDataReply(data)) { + console.error( + `Received unexpected message type ${data.type} in a ${method} operation` + ); + return; + } + + inspected.lastData = data.data; + inspected.lastKey = data.key; + + observer.next(data); + + // for a `get` method the first `ok` message + // also marks the end of the stream. + if (method === 'get' && data.type === 'ok') { + shouldCancel = false; + observer.complete(); + } + }, + error: (err) => { + console.error(err, attrs); + observer.error(err); + }, + complete: () => { + observer.complete(); + }, + }); + + if (isDevMode()) { + // make sure we remove the "active" request when the subscription + // goes down + subscription.add(() => { + const active = this.inspectActiveRequests(); + delete active[request.id]; + this.activeRequests.next(active); + }); + } + + return () => { + subscription.unsubscribe(); + }; + }); + } + + private multiplex( + req: RequestMessage, + cancel: (() => RequestMessage | null) | null + ): Observable { + return new Observable((observer) => { + if (this.connectedSubject.getValue()) { + // Try to directly send the request to the backend + this._streams$.set(req.id, observer); + this.ws$!.next(req); + } else { + // in case of an error we just add the request as + // "pending" and wait for the connection to be + // established. + console.warn( + `Failed to send request ${req.id}:${req.type}, marking as pending ...` + ); + this._pendingCalls$.set(req.id, { + request: req, + observer: observer, + }); + } + + return () => { + // Try to cancel the request but ingore + // any errors here. + try { + if (cancel !== null) { + const cancelMsg = cancel(); + if (!!cancelMsg) { + this.ws$!.next(cancelMsg); + } + } + } catch (err) { } + + this._pendingCalls$.delete(req.id); + this._streams$.delete(req.id); + }; + }); + } + + /** + * Inject a message into a PortAPI stream. + * + * @param id The request ID to inject msg into. + * @param msg The message to inject. + */ + _injectMessage(id: string, msg: DataReply) { + // we are using runTask here so change-detection is + // triggered as needed + this.ngZone.runTask(() => { + const req = this.activeRequests.getValue()[id]; + if (!req) { + return; + } + + req.observer.next(msg as DataReply); + }); + } + + /** + * Injects a 'ok' type message + * + * @param id The ID of the request to inject into + * @param data The data blob to inject + * @param key [optional] The key of the entry to inject + */ + _injectData(id: string, data: any, key: string = '') { + this._injectMessage(id, { type: 'ok', data: data, key, id: id }); + } + + /** + * Patches the last message received on id by deeply merging + * data and re-injects that message. + * + * @param id The ID of the request + * @param data The patch to apply and reinject + */ + _patchLast(id: string, data: any) { + const req = this.activeRequests.getValue()[id]; + if (!req || !req.lastData) { + return; + } + + const newPayload = mergeDeep({}, req.lastData, data); + this._injectData(id, newPayload, req.lastKey); + } + + private stripMeta(obj: T): T { + let copy = { + ...obj, + _meta: undefined, + }; + return copy; + } + + /** + * Creates a new websocket subject and configures appropriate serializer + * and deserializer functions for PortAPI. + * + * @private + */ + private createWebsocket(): WebSocketSubject { + return this.websocketFactory.createConnection< + ReplyMessage | RequestMessage + >({ + url: this.wsEndpoint, + serializer: (msg) => { + try { + return serializeMessage(msg); + } catch (err) { + console.error('serialize message', err); + return { + type: 'error', + }; + } + }, + // deserializeMessage also supports RequestMessage so cast as any + deserializer: ((msg: any) => { + try { + const res = deserializeMessage(msg); + return res; + } catch (err) { + console.error('deserialize message', err); + return { + type: 'error', + }; + } + }), + binaryType: 'arraybuffer', + openObserver: { + next: () => { + console.log('[portapi] connection to portmaster established'); + this.connectedSubject.next(true); + this._flushPendingMethods(); + }, + }, + closeObserver: { + next: () => { + console.log('[portapi] connection to portmaster closed'); + this.connectedSubject.next(false); + }, + }, + closingObserver: { + next: () => { + console.log('[portapi] connection to portmaster closing'); + }, + }, + }); + } +} + +// Counts the number of "truthy" datafields in obj. +function countTruthyDataFields(obj: { [key: string]: any }): number { + let count = 0; + Object.keys(obj).forEach((key) => { + let value = obj[key]; + if (!!value) { + count++; + } + }); + return count; +} + +function isObject(item: any): item is Object { + return item && typeof item === 'object' && !Array.isArray(item); +} + +export function mergeDeep(target: any, ...sources: any): any { + if (!sources.length) return target; + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + mergeDeep(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return mergeDeep(target, ...sources); +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts new file mode 100644 index 00000000..349c7b9f --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts @@ -0,0 +1,453 @@ +import { iif, MonoTypeOperatorFunction, of, Subscriber, throwError } from 'rxjs'; +import { concatMap, delay, retryWhen } from 'rxjs/operators'; + +/** +* ReplyType contains all possible message types of a reply. +*/ +export type ReplyType = 'ok' + | 'upd' + | 'new' + | 'del' + | 'success' + | 'error' + | 'warning' + | 'done'; + +/** +* RequestType contains all possible message types of a request. +*/ +export type RequestType = 'get' + | 'query' + | 'sub' + | 'qsub' + | 'create' + | 'update' + | 'insert' + | 'delete' + | 'cancel'; + +// RecordMeta describes the meta-data object that is part of +// every API resource. +export interface RecordMeta { + // Created hold a unix-epoch timestamp when the record has been + // created. + Created: number; + // Deleted hold a unix-epoch timestamp when the record has been + // deleted. + Deleted: number; + // Expires hold a unix-epoch timestamp when the record has been + // expires. + Expires: number; + // Modified hold a unix-epoch timestamp when the record has been + // modified last. + Modified: number; + // Key holds the database record key. + Key: string; +} + +export interface Process extends Record { + Name: string; + UserID: number; + UserName: string; + UserHome: string; + Pid: number; + Pgid: number; + CreatedAt: number; + ParentPid: number; + ParentCreatedAt: number; + Path: string; + ExecName: string; + Cwd: string; + CmdLine: string; + FirstArg: string; + Env: { + [key: string]: string + } | null; + Tags: { + Key: string; + Value: string; + }[] | null; + MatchingPath: string; + PrimaryProfileID: string; + FirstSeen: number; + LastSeen: number; + Error: string; + ExecHashes: { + [key: string]: string + } | null; +} + +// Record describes the base record structure of all API resources. +export interface Record { + _meta?: RecordMeta; +} + +/** +* All possible MessageType that are available in PortAPI. +*/ +export type MessageType = RequestType | ReplyType; + +/** +* BaseMessage describes the base message type that is exchanged +* via PortAPI. +*/ +export interface BaseMessage { + // ID of the request. Used to correlated (multiplex) requests and + // responses across a single websocket connection. + id: string; + // Type is the request/response message type. + type: M; +} + +/** +* DoneReply marks the end of a PortAPI stream. +*/ +export interface DoneReply extends BaseMessage<'done'> { } + +/** +* DataReply is either sent once as a result on a `get` request or +* is sent multiple times in the course of a PortAPI stream. +*/ +export interface DataReply extends BaseMessage<'ok' | 'upd' | 'new' | 'del'> { + // Key is the database key including the database prefix. + key: string; + // Data is the actual data of the entry. + data: T; +} + +/** + * Returns true if d is a DataReply message type. + * + * @param d The reply message to check + */ +export function isDataReply(d: ReplyMessage): d is DataReply { + return d.type === 'ok' + || d.type === 'upd' + || d.type === 'new' + || d.type === 'del'; + //|| d.type === 'done'; // done is actually not correct +} + +/** +* SuccessReply is used to mark an operation as successfully. It does not carry any +* data. Think of it as a "201 No Content" in HTTP. +*/ +export interface SuccessReply extends BaseMessage<'success'> { } + +/** +* ErrorReply describes an error that happened while processing a +* request. Note that an `error` type message may be sent for single +* and response-stream requests. In case of a stream the `error` type +* message marks the end of the stream. See WarningReply for a simple +* warning message that can be transmitted via PortAPI. +*/ +export interface ErrorReply extends BaseMessage<'error'> { + // Message is the error message from the backend. + message: string; +} + +/** +* WarningReply contains a warning message that describes an error +* condition encountered when processing a single entitiy of a +* response stream. In contrast to `error` type messages, a `warning` +* can only occure during data streams and does not end the stream. +*/ +export interface WarningReply extends BaseMessage<'warning'> { + // Message describes the warning/error condition the backend + // encountered. + message: string; +} + +/** +* QueryRequest defines the payload for `query`, `sub` and `qsub` message +* types. The result of a query request is always a stream of responses. +* See ErrorReply, WarningReply and DoneReply for more information. +*/ +export interface QueryRequest extends BaseMessage<'query' | 'sub' | 'qsub'> { + // Query is the query for the database. + query: string; +} + +/** +* KeyRequests defines the payload for a `get` or `delete` request. Those +* message type only carry the key of the database entry to delete. Note that +* `delete` can only return a `success` or `error` type message while `get` will +* receive a `ok` or `error` type message. +*/ +export interface KeyRequest extends BaseMessage<'delete' | 'get'> { + // Key is the database entry key. + key: string; +} + + +/** +* DataRequest is used during create, insert or update operations. +* TODO(ppacher): check what's the difference between create and insert, +* both seem to error when trying to create a new entry. +*/ +export interface DataRequest extends BaseMessage<'update' | 'create' | 'insert'> { + // Key is the database entry key. + key: string; + // Data is the data to store. + data: T; +} + +/** + * CancelRequest can be sent on stream operations to early-abort the request. + */ +export interface CancelRequest extends BaseMessage<'cancel'> { } + +/** +* ReplyMessage is a union of all reply message types. +*/ +export type ReplyMessage = DataReply + | DoneReply + | SuccessReply + | WarningReply + | ErrorReply; + +/** +* RequestMessage is a union of all request message types. +*/ +export type RequestMessage = QueryRequest + | KeyRequest + | DataRequest + | CancelRequest; + +/** +* Requestable can be used to accept only properties that match +* the request message type M. +*/ +export type Requestable = RequestMessage & { type: M }; + +/** + * Returns true if m is a cancellable message type. + * + * @param m The message type to check. + */ +export function isCancellable(m: MessageType): boolean { + switch (m) { + case 'qsub': + case 'sub': + return true; + default: + return false; + } +} + +/** + * Reflects a currently in-flight PortAPI request. Used to + * intercept and mangle with responses. + */ +export interface InspectedActiveRequest { + // The type of request. + type: RequestType; + // The actual request payload. + // @todo(ppacher): typings + payload: any; + // The request observer. Use to inject data + // or complete/error the subscriber. Use with + // care! + observer: Subscriber>; + // Counter for the number of messages received + // for this request. + messagesReceived: number; + // The last data received on the request + lastData: any; + // The last key received on the request + lastKey: string; +} + +export interface RetryableOpts { + // A delay in milliseconds before retrying an operation. + retryDelay?: number; + // The maximum number of retries. + maxRetries?: number; +} + +export interface ProfileImportResult extends ImportResult { + replacesProfiles: string[]; +} + +export interface ImportResult { + restartRequired: boolean; + replacesExisting: boolean; + containsUnknown: boolean; +} + +/** + * Returns a RxJS operator function that implements a retry pipeline + * with a configurable retry delay and an optional maximum retry count. + * If maxRetries is reached the last error captured is thrown. + * + * @param opts Configuration options for the retryPipeline. + * see {@type RetryableOpts} for more information. + */ +export function retryPipeline({ retryDelay, maxRetries }: RetryableOpts = {}): MonoTypeOperatorFunction { + return retryWhen(errors => errors.pipe( + // use concatMap to keep the errors in order and make sure + // they don't execute in parallel. + concatMap((e, i) => + iif( + // conditional observable seletion, throwError if i > maxRetries + // or a retryDelay otherwise + () => i > (maxRetries || Infinity), + throwError(() => e), + of(e).pipe(delay(retryDelay || 1000)) + ) + ) + )) +} + +export interface WatchOpts extends RetryableOpts { + // Whether or not `new` updates should be filtered + // or let through. See {@method PortAPI.watch} for + // more information. + ingoreNew?: boolean; + + ignoreDelete?: boolean; +} + + +/** +* Serializes a request or reply message into it's wire format. +* +* @param msg The request or reply messsage to serialize +*/ +export function serializeMessage(msg: RequestMessage | ReplyMessage): any { + if (msg === undefined) { + return undefined; + } + + let blob = `${msg.id}|${msg.type}`; + + switch (msg.type) { + case 'done': // reply + case 'success': // reply + case 'cancel': // request + break; + + case 'error': // reply + case 'warning': // reply + blob += `|${msg.message}` + break; + + case 'ok': // reply + case 'upd': // reply + case 'new': // reply + case 'insert': // request + case 'update': // request + case 'create': // request + blob += `|${msg.key}|J${JSON.stringify(msg.data)}` + break; + + + case 'del': // reply + case 'get': // request + case 'delete': // request + blob += `|${msg.key}` + break; + + case 'query': // request + case 'sub': // request + case 'qsub': // request + blob += `|query ${msg.query}` + break; + + default: + // We need (msg as any) here because typescript knows that we covered + // all possible values above and that .type can never be something else. + // Still, we want to guard against unexpected portmaster message + // types. + console.error(`Unknown message type ${(msg as any).type}`); + } + + return blob; +} + +/** +* Deserializes (loads) a PortAPI message from a WebSocket message event. +* +* @param event The WebSocket MessageEvent to parse. +*/ +export function deserializeMessage(event: MessageEvent): RequestMessage | ReplyMessage { + let data: string; + + if (typeof event.data !== 'string') { + data = new TextDecoder("utf-8").decode(event.data) + } else { + data = event.data; + } + + const parts = data.split("|"); + + if (parts.length < 2) { + throw new Error(`invalid number of message parts, expected 3-4 but got ${parts.length}`); + } + + const id = parts[0]; + const type = parts[1] as MessageType; + + var msg: Partial = { + id, + type, + } + + if (parts.length > 4) { + parts[3] = parts.slice(3).join('|') + } + + switch (msg.type) { + case 'done': // reply + case 'success': // reply + case 'cancel': // request + break; + + case 'error': // reply + case 'warning': // reply + msg.message = parts[2]; + break; + + case 'ok': // reply + case 'upd': // reply + case 'new': // reply + case 'insert': // request + case 'update': // request + case 'create': // request + msg.key = parts[2]; + try { + if (parts[3][0] === 'J') { + msg.data = JSON.parse(parts[3].slice(1)); + } else { + msg.data = parts[3]; + } + } catch (e) { + console.log(e, data) + } + break; + + case 'del': // reply + case 'get': // request + case 'delete': // request + msg.key = parts[2]; + break; + + case 'query': // request + case 'sub': // request + case 'qsub': // request + msg.query = parts[2]; + if (msg.query.startsWith("query ")) { + msg.query = msg.query.slice(6); + } + break; + + default: + // We need (msg as any) here because typescript knows that we covered + // all possible values above and that .type can never be something else. + // Still, we want to guard against unexpected portmaster message + // types. + console.error(`Unknown message type ${(msg as any).type}`); + } + + return msg as (ReplyMessage | RequestMessage); // it's not partitial anymore +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts new file mode 100644 index 00000000..fc0a6047 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts @@ -0,0 +1,171 @@ +import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http"; +import { Inject, Injectable } from "@angular/core"; +import { BehaviorSubject, Observable, of } from "rxjs"; +import { filter, map, share, switchMap } from "rxjs/operators"; +import { FeatureID } from "./features"; +import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service'; +import { Feature, Pin, SPNStatus, UserProfile } from "./spn.types"; + +@Injectable({ providedIn: 'root' }) +export class SPNService { + + /** Emits the SPN status whenever it changes */ + status$: Observable; + + profile$ = this.watchProfile() + .pipe( + share({ connector: () => new BehaviorSubject(undefined) }), + filter(val => val !== undefined) + ) as Observable; + + private pins$: Observable; + + constructor( + private portapi: PortapiService, + private http: HttpClient, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string, + ) { + this.status$ = this.portapi.watch('runtime:spn/status', { ignoreDelete: true }) + .pipe( + share({ connector: () => new BehaviorSubject(null) }), + filter(val => val !== null), + ) + + this.pins$ = this.status$ + .pipe( + switchMap(status => { + if (status.Status !== "disabled") { + return this.portapi.watchAll("map:main/", { retryDelay: 50000 }) + } + + return of([] as Pin[]); + }), + share({ connector: () => new BehaviorSubject(undefined) }), + filter(val => val !== undefined) + ) as Observable; + } + + /** + * Watches all pins of the "main" SPN map. + */ + watchPins(): Observable { + return this.pins$; + } + + /** + * Encodes a unicode string to base64. + * See https://developer.mozilla.org/en-US/docs/Web/API/btoa + * and https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings + */ + b64EncodeUnicode(str: string): string { + return window.btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { + return String.fromCharCode(parseInt(p1, 16)) + })) + } + + /** + * Logs into the SPN user account + */ + login({ username, password }: { username: string, password: string }): Observable> { + return this.http.post(`${this.httpAPI}/v1/spn/account/login`, undefined, { + headers: { + Authorization: `Basic ${this.b64EncodeUnicode(username + ':' + password)}` + }, + responseType: 'text', + observe: 'response' + }); + } + + /** + * Log out of the SPN user account + * + * @param purge Whether or not the portmaster should keep user/device information for the next login + */ + logout(purge = false): Observable> { + let params = new HttpParams(); + if (!!purge) { + params = params.set("purge", "true") + } + return this.http.delete(`${this.httpAPI}/v1/spn/account/logout`, { + params, + responseType: 'text', + observe: 'response' + }) + } + + watchEnabledFeatures(): Observable<(Feature & { enabled: boolean })[]> { + return this.profile$ + .pipe( + switchMap(profile => { + return this.loadFeaturePackages() + .pipe( + map(features => { + return features.map(feature => { + // console.log(feature, profile?.current_plan?.feature_ids) + return { + ...feature, + enabled: feature.RequiredFeatureID === FeatureID.None || profile?.current_plan?.feature_ids?.includes(feature.RequiredFeatureID) || false, + } + }) + }) + ) + }) + ); + } + + /** Returns a list of all feature packages */ + loadFeaturePackages(): Observable { + return this.http.get<{ Features: Feature[] }>(`${this.httpAPI}/v1/account/features`) + .pipe( + map(response => response.Features.map(feature => { + return { + ...feature, + IconURL: `${this.httpAPI}/v1/account/features/${feature.ID}/icon`, + } + })) + ); + } + + /** + * Returns the current SPN user profile. + * + * @param refresh Whether or not the user profile should be refreshed from the ticket agent + * @returns + */ + userProfile(refresh = false): Observable { + let params = new HttpParams(); + if (!!refresh) { + params = params.set("refresh", true) + } + return this.http.get(`${this.httpAPI}/v1/spn/account/user/profile`, { + params + }); + } + + /** + * Watches the user profile. It will emit null if there is no profile available yet. + */ + watchProfile(): Observable { + let hasSent = false; + return this.portapi.watch('core:spn/account/user', { ignoreDelete: true }, { forwardDone: true }) + .pipe( + filter(result => { + if ('type' in result && result.type === 'done') { + if (hasSent) { + return false; + } + } + + return true + }), + map(result => { + hasSent = true; + if ('type' in result) { + return null; + } + + return result; + }) + ); + } +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts new file mode 100644 index 00000000..b2e7caaf --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts @@ -0,0 +1,104 @@ +import { FeatureID } from './features'; +import { CountryInfo, GeoCoordinates, IntelEntity } from './network.types'; +import { Record } from './portapi.types'; + +export interface SPNStatus extends Record { + Status: 'failed' | 'disabled' | 'connecting' | 'connected'; + HomeHubID: string; + HomeHubName: string; + ConnectedIP: string; + ConnectedTransport: string; + ConnectedCountry: CountryInfo | null; + ConnectedSince: string | null; +} + +export interface Pin extends Record { + ID: string; + Name: string; + FirstSeen: string; + EntityV4?: IntelEntity | null; + EntityV6?: IntelEntity | null; + States: string[]; + SessionActive: boolean; + HopDistance: number; + ConnectedTo: { + [key: string]: Lane, + }; + Route: string[] | null; + VerifiedOwner: string; +} + +export interface Lane { + HubID: string; + Capacity: number; + Latency: number; +} + +export function getPinCoords(p: Pin): GeoCoordinates | null { + if (p.EntityV4 && p.EntityV4.Coordinates) { + return p.EntityV4.Coordinates; + } + return p.EntityV6?.Coordinates || null; +} + +export interface Device { + name: string; + id: string; +} + +export interface Subscription { + ends_at: string; + state: 'manual' | 'active' | 'cancelled'; + next_billing_date: string; + payment_provider: string; +} + +export interface Plan { + name: string; + amount: number; + months: number; + renewable: boolean; + feature_ids: FeatureID[]; +} + +export interface View { + Message: string; + ShowAccountData: boolean; + ShowAccountButton: boolean; + ShowLoginButton: boolean; + ShowRefreshButton: boolean; + ShowLogoutButton: boolean; +} + +export interface UserProfile extends Record { + username: string; + state: string; + balance: number; + device: Device | null; + subscription: Subscription | null; + current_plan: Plan | null; + next_plan: Plan | null; + view: View | null; + LastNotifiedOfEnd?: string; + LoggedInAt?: string; +} + +export interface Package { + Name: string; + HexColor: string; +} + +export interface Feature { + ID: string; + Name: string; + ConfigKey: string; + ConfigScope: string; + RequiredFeatureID: FeatureID; + InPackage: Package | null; + Comment: string; + Beta?: boolean; + ComingSoon?: boolean; + + // does not come from the PM API but is set by SPNService + IconURL: string; +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts new file mode 100644 index 00000000..80b97573 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts @@ -0,0 +1,13 @@ + +export function deepClone(o?: T | null): T { + if (o === null || o === undefined) { + return null as any as T; + } + + let _out: T = (Array.isArray(o) ? [] : {}) as T; + for (let _key in (o as T)) { + let v = o[_key]; + _out[_key] = (typeof v === "object") ? deepClone(v) : v; + } + return _out as T; +} diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts new file mode 100644 index 00000000..c42efa8d --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { webSocket, WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket'; + +@Injectable() +export class WebsocketService { + constructor() { } + + /** + * createConnection creates a new websocket connection using opts. + * + * @param opts Options for the websocket connection. + */ + createConnection(opts: WebSocketSubjectConfig): WebSocketSubject { + return webSocket(opts); + } +} + diff --git a/desktop/angular/projects/safing/portmaster-api/src/public-api.ts b/desktop/angular/projects/safing/portmaster-api/src/public-api.ts new file mode 100644 index 00000000..9097761e --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/public-api.ts @@ -0,0 +1,22 @@ +/* + * Public API Surface of portmaster-api + */ + +export * from './lib/app-profile.service'; +export * from './lib/app-profile.types'; +export * from './lib/config.service'; +export * from './lib/config.types'; +export * from './lib/core.types'; +export * from './lib/debug-api.service'; +export * from './lib/features'; +export * from './lib/meta-api.service'; +export * from './lib/module'; +export * from './lib/netquery.service'; +export * from './lib/network.types'; +export * from './lib/portapi.service'; +export * from './lib/portapi.types'; +export * from './lib/spn.service'; +export * from './lib/spn.types'; +export * from './lib/utils'; +export * from './lib/websocket.service'; + diff --git a/desktop/angular/projects/safing/portmaster-api/src/test.ts b/desktop/angular/projects/safing/portmaster-api/src/test.ts new file mode 100644 index 00000000..43808367 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/src/test.ts @@ -0,0 +1,15 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js'; +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); diff --git a/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json new file mode 100644 index 00000000..c9f14589 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/test.ts", + "testing/**/*", + "**/*.spec.ts" + ] +} diff --git a/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json new file mode 100644 index 00000000..71b135f6 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json @@ -0,0 +1,7 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, +} diff --git a/desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json b/desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json new file mode 100644 index 00000000..258250d2 --- /dev/null +++ b/desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json @@ -0,0 +1,18 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "files": [ + "testing/**/*.ts" + ], + "include": [ + "testing/**/*.ts", + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/desktop/angular/projects/safing/ui/.eslintrc.json b/desktop/angular/projects/safing/ui/.eslintrc.json new file mode 100644 index 00000000..91e1f496 --- /dev/null +++ b/desktop/angular/projects/safing/ui/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "extends": "../../../.eslintrc.json", + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts" + ], + "parserOptions": { + "project": [ + "projects/safing/ui/tsconfig.lib.json", + "projects/safing/ui/tsconfig.spec.json" + ], + "createDefaultProgram": true + }, + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "sfng", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "sfng", + "style": "kebab-case" + } + ] + } + }, + { + "files": [ + "*.html" + ], + "rules": {} + } + ] +} diff --git a/desktop/angular/projects/safing/ui/README.md b/desktop/angular/projects/safing/ui/README.md new file mode 100644 index 00000000..cf11e371 --- /dev/null +++ b/desktop/angular/projects/safing/ui/README.md @@ -0,0 +1,24 @@ +# Ui + +This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.2.0. + +## Code scaffolding + +Run `ng generate component component-name --project ui` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ui`. +> Note: Don't forget to add `--project ui` or else it will be added to the default project in your `angular.json` file. + +## Build + +Run `ng build ui` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Publishing + +After building your library with `ng build ui`, go to the dist folder `cd dist/ui` and run `npm publish`. + +## Running unit tests + +Run `ng test ui` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/desktop/angular/projects/safing/ui/karma.conf.js b/desktop/angular/projects/safing/ui/karma.conf.js new file mode 100644 index 00000000..8975477b --- /dev/null +++ b/desktop/angular/projects/safing/ui/karma.conf.js @@ -0,0 +1,44 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution with `random: false` + // or set a specific seed with `seed: 4321` + }, + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join(__dirname, '../../../coverage/safing/ui'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' } + ] + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true + }); +}; diff --git a/desktop/angular/projects/safing/ui/ng-package.json b/desktop/angular/projects/safing/ui/ng-package.json new file mode 100644 index 00000000..4a890c44 --- /dev/null +++ b/desktop/angular/projects/safing/ui/ng-package.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist-lib/safing/ui", + "lib": { + "entryFile": "src/public-api.ts" + }, + "assets": [ + "theming.scss", + "**/_*.scss" + ] +} diff --git a/desktop/angular/projects/safing/ui/package.json b/desktop/angular/projects/safing/ui/package.json new file mode 100644 index 00000000..52fa541a --- /dev/null +++ b/desktop/angular/projects/safing/ui/package.json @@ -0,0 +1,17 @@ +{ + "name": "@safing/ui", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "~12.2.0", + "@angular/core": "~12.2.0", + "@angular/cdk": "~12.2.0" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "exports": { + "./theming": { + "sass": "./theming.scss" + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html new file mode 100644 index 00000000..6dbc7430 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html @@ -0,0 +1 @@ + diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts new file mode 100644 index 00000000..3c152842 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts @@ -0,0 +1,116 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { SfngAccordionComponent } from './accordion'; + +@Component({ + selector: 'sfng-accordion-group', + templateUrl: './accordion-group.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngAccordionGroupComponent implements OnDestroy { + /** @private Currently registered accordion components */ + accordions: SfngAccordionComponent[] = []; + + /** + * A template-ref to render as the header for each accordion-component. + * Receives the accordion data as an $implicit context. + */ + @Input() + set headerTemplate(v: TemplateRef | null) { + this._headerTemplate = v; + + if (!!this.accordions.length) { + this.accordions.forEach(a => { + a.headerTemplate = v; + a.cdr.markForCheck(); + }) + } + } + get headerTemplate() { return this._headerTemplate } + private _headerTemplate: TemplateRef | null = null; + + /** Whether or not one or more components can be expanded. */ + @Input() + set singleMode(v: any) { + this._singleMode = coerceBooleanProperty(v); + } + get singleMode() { return this._singleMode } + private _singleMode = false; + + /** Whether or not the accordion is disabled and does not allow expanding */ + @Input() + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v); + if (this._disabled) { + this.accordions.forEach(a => a.active = false); + } + } + get disabled(): boolean { return this._disabled; } + private _disabled = false; + + /** A list of subscriptions to the activeChange output of the registered accordion-components */ + private subscriptions: Subscription[] = []; + + /** + * Registeres an accordion component to be handled together with this + * accordion group. + * + * @param a The accordion component to register + */ + register(a: SfngAccordionComponent) { + this.accordions.push(a); + + // Tell the accordion-component about the default header-template. + if (!a.headerTemplate) { + a.headerTemplate = this.headerTemplate; + } + + // Subscribe to the activeChange output of the registered + // accordion and call toggle() for each event emitted. + this.subscriptions.push(a.activeChange.subscribe(() => { + if (this.disabled) { + return; + } + + this.toggle(a); + })) + } + + /** + * Unregisters a accordion component + * + * @param a The accordion component to unregister + */ + unregister(a: SfngAccordionComponent) { + const index = this.accordions.indexOf(a); + if (index === -1) return; + + const subscription = this.subscriptions[index]; + + subscription.unsubscribe(); + this.accordions = this.accordions.splice(index, 1); + this.subscriptions = this.subscriptions.splice(index, 1); + } + + ngOnDestroy() { + this.subscriptions.forEach(s => s.unsubscribe()); + this.subscriptions = []; + this.accordions = []; + } + + /** + * Expand an accordion component and collaps all others if + * single-mode is selected. + * + * @param a The accordion component to toggle. + */ + private toggle(a: SfngAccordionComponent) { + if (!a.active && this._singleMode) { + this.accordions?.forEach(a => a.active = false); + } + + a.active = !a.active; + } + +} diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html new file mode 100644 index 00000000..4d47b842 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html @@ -0,0 +1,10 @@ +
+ + +
+ +
+ + + +
diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.module.ts b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.module.ts new file mode 100644 index 00000000..7de494f9 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.module.ts @@ -0,0 +1,19 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { SfngAccordionComponent } from "./accordion"; +import { SfngAccordionGroupComponent } from "./accordion-group"; + +@NgModule({ + imports: [ + CommonModule, + ], + declarations: [ + SfngAccordionGroupComponent, + SfngAccordionComponent, + ], + exports: [ + SfngAccordionGroupComponent, + SfngAccordionComponent, + ] +}) +export class SfngAccordionModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.ts b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.ts new file mode 100644 index 00000000..1c3f6ec5 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.ts @@ -0,0 +1,88 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Optional, Output, TemplateRef, TrackByFunction } from '@angular/core'; +import { fadeInAnimation, fadeOutAnimation } from '../animations'; +import { SfngAccordionGroupComponent } from './accordion-group'; + +@Component({ + selector: 'sfng-accordion', + templateUrl: './accordion.html', + exportAs: 'sfngAccordion', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeOutAnimation + ] +}) +export class SfngAccordionComponent implements OnInit, OnDestroy { + /** @deprecated in favor of [data] */ + @Input() + title: string = ''; + + /** A reference to the component provided via the template context */ + component = this; + + /** + * The data the accordion component is used for. This is passed as an $implicit context + * to the header template. + */ + @Input() + data: T | undefined = undefined; + + @Input() + trackBy: TrackByFunction = (_, c) => c + + /** Whether or not the accordion component starts active. */ + @Input() + set active(v: any) { + this._active = coerceBooleanProperty(v); + } + get active() { + return this._active; + } + private _active: boolean = false; + + /** Emits whenever the active value changes. Supports two-way bindings. */ + @Output() + activeChange = new EventEmitter(); + + /** + * The header-template to render for this component. If null, the default template from + * the parent accordion-group will be used. + */ + @Input() + headerTemplate: TemplateRef | null = null; + + @HostBinding('class.active') + /** @private Whether or not the accordion should have the 'active' class */ + get activeClass(): string { + return this.active; + } + + ngOnInit(): void { + // register at our parent group-component (if any). + this.group?.register(this); + } + + ngOnDestroy(): void { + this.group?.unregister(this); + } + + /** + * Toggle the active-state of the accordion-component. + * + * @param event The mouse event. + */ + toggle(event?: Event) { + if (!!this.group && this.group.disabled) { + return; + } + + event?.preventDefault(); + this.activeChange.emit(!this.active); + } + + constructor( + public cdr: ChangeDetectorRef, + @Optional() public group: SfngAccordionGroupComponent, + ) { } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/index.ts b/desktop/angular/projects/safing/ui/src/lib/accordion/index.ts new file mode 100644 index 00000000..c06e6707 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/accordion/index.ts @@ -0,0 +1,4 @@ +export { SfngAccordionComponent } from './accordion'; +export { SfngAccordionGroupComponent } from './accordion-group'; +export { SfngAccordionModule } from './accordion.module'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/animations/index.ts b/desktop/angular/projects/safing/ui/src/lib/animations/index.ts new file mode 100644 index 00000000..e1613052 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/animations/index.ts @@ -0,0 +1,88 @@ +import { animate, query, stagger, style, transition, trigger } from '@angular/animations'; + +export const fadeInAnimation = trigger( + 'fadeIn', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateY(-5px)' }), + animate('120ms cubic-bezier(0, 0, 0.2, 1)', + style({ opacity: 1, transform: 'translateY(0px)' })) + ] + ), + ] +); + +export const fadeOutAnimation = trigger( + 'fadeOut', + [ + transition( + ':leave', + [ + style({ opacity: 1, transform: 'translateY(0px)' }), + animate('120ms cubic-bezier(0, 0, 0.2, 1)', + style({ opacity: 0, transform: 'translateY(-5px)' })) + ] + ), + ] +); + +export const fadeInListAnimation = trigger( + 'fadeInList', + [ + transition(':enter, * => 0, * => -1', []), + transition(':increment', [ + query(':enter', [ + style({ opacity: 0 }), + stagger(5, [ + animate('300ms ease-out', style({ opacity: 1 })), + ]), + ], { optional: true }) + ]), + ] +) + +export const moveInOutAnimation = trigger( + 'moveInOut', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateX(100%)' }), + animate('.2s ease-in', + style({ opacity: 1, transform: 'translateX(0%)' })) + ] + ), + transition( + ':leave', + [ + style({ opacity: 1 }), + animate('.2s ease-out', + style({ opacity: 0, transform: 'translateX(100%)' })) + ] + ) + ] +) + +export const moveInOutListAnimation = trigger( + 'moveInOutList', + [ + transition(':enter, * => 0, * => -1', []), + transition(':increment', [ + query(':enter', [ + style({ opacity: 0, transform: 'translateX(100%)' }), + stagger(50, [ + animate('200ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' })), + ]), + ], { optional: true }) + ]), + transition(':decrement', [ + query(':leave', [ + stagger(-50, [ + animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(100%)' })), + ]), + ], { optional: true }) + ]), + ] +) diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/_confirm.dialog.scss b/desktop/angular/projects/safing/ui/src/lib/dialog/_confirm.dialog.scss new file mode 100644 index 00000000..a0d459a8 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/_confirm.dialog.scss @@ -0,0 +1,95 @@ +.sfng-confirm-dialog { + display: flex; + flex-direction: column; + align-items: flex-start; + + caption { + @apply text-sm; + opacity: .6; + font-size: .6rem; + } + + h1 { + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 1rem; + } + + .message, + h1 { + flex-shrink: 0; + text-overflow: ellipsis; + word-break: normal; + } + + .message { + font-size: 0.75rem; + flex-grow: 1; + opacity: .6; + max-width: 300px; + } + + .message~input { + margin-top: 0.5rem; + font-size: 95%; + } + + .close-icon { + position: absolute; + top: 1rem; + right: 1rem; + opacity: .7; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + + input[type="text"] { + @apply text-primary; + @apply bg-gray-500 border-gray-400 bg-opacity-75 border-opacity-75; + + &::placeholder { + @apply text-tertiary; + } + } + + .actions { + margin-top: 1rem; + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + + button.action-button { + &:not(:last-child) { + margin-right: 0.5rem; + } + + &:not(.outline) { + @apply bg-blue; + } + + &.danger { + @apply bg-red-300; + } + + &.outline { + @apply outline-none; + @apply border; + @apply border-gray-400; + } + } + + &>span { + display: flex; + align-items: center; + + label { + margin-left: .5rem; + user-select: none; + } + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/_dialog.scss b/desktop/angular/projects/safing/ui/src/lib/dialog/_dialog.scss new file mode 100644 index 00000000..22300126 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/_dialog.scss @@ -0,0 +1,28 @@ +sfng-dialog-container { + .container { + display: block; + box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.75); + @apply p-6; + @apply bg-gray-300; + @apply rounded; + min-width: 20rem; + width: fit-content; + position: relative; + } + + #drag-handle { + display: block; + height: 6px; + background-color: white; + opacity: .4; + border-radius: 3px; + position: absolute; + bottom: calc(0.5rem - 2px); + width: 30%; + left: calc(50% - 15%); + + &:hover { + opacity: .8; + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.html b/desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.html new file mode 100644 index 00000000..0bbcf275 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.html @@ -0,0 +1,22 @@ +
+ {{config.caption}} + + + + + +

{{config.header}}

+ + {{ config.message }} + + + +
+ +
+
diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.ts new file mode 100644 index 00000000..c3c1f888 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, Inject, InjectionToken } from '@angular/core'; +import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref'; + +export interface ConfirmDialogButton { + text: string; + id: string; + class?: 'danger' | 'outline'; +} + +export interface ConfirmDialogConfig { + buttons?: ConfirmDialogButton[]; + canCancel?: boolean; + header?: string; + message?: string; + caption?: string; + inputType?: 'text' | 'password'; + inputModel?: string; + inputPlaceholder?: string; +} + +export const CONFIRM_DIALOG_CONFIG = new InjectionToken('ConfirmDialogConfig'); + +@Component({ + templateUrl: './confirm.dialog.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngConfirmDialogComponent { + constructor( + @Inject(SFNG_DIALOG_REF) private dialogRef: SfngDialogRef, + @Inject(CONFIRM_DIALOG_CONFIG) public config: ConfirmDialogConfig, + ) { + if (config.inputType !== undefined && config.inputModel === undefined) { + config.inputModel = ''; + } + } + + select(action?: string) { + this.dialogRef.close(action || null); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.animations.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.animations.ts new file mode 100644 index 00000000..14e0fe29 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.animations.ts @@ -0,0 +1,19 @@ +import { animate, state, style, transition, trigger } from "@angular/animations"; + +export const dialogAnimation = trigger( + 'dialogContainer', + [ + state('void, exit', style({ opacity: 0, transform: 'scale(0.7)' })), + state('enter', style({ transform: 'none', opacity: 1 })), + transition( + '* => enter', + animate('120ms cubic-bezier(0, 0, 0.2, 1)', + style({ opacity: 1, transform: 'translateY(0px)' })) + ), + transition( + '* => void, * => exit', + animate('120ms cubic-bezier(0, 0, 0.2, 1)', + style({ opacity: 0, transform: 'scale(0.7)' })) + ), + ] +); diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.container.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.container.ts new file mode 100644 index 00000000..d3565f47 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.container.ts @@ -0,0 +1,76 @@ +import { AnimationEvent } from '@angular/animations'; +import { CdkDrag } from '@angular/cdk/drag-drop'; +import { CdkPortalOutlet, ComponentPortal, Portal, TemplatePortal } from '@angular/cdk/portal'; +import { ChangeDetectorRef, Component, ComponentRef, EmbeddedViewRef, HostBinding, HostListener, InjectionToken, Input, ViewChild } from '@angular/core'; +import { Subject } from 'rxjs'; +import { dialogAnimation } from './dialog.animations'; + +export const SFNG_DIALOG_PORTAL = new InjectionToken>('SfngDialogPortal'); + +export type SfngDialogState = 'opening' | 'open' | 'closing' | 'closed'; + +@Component({ + selector: 'sfng-dialog-container', + template: ` +
+
+ +
+ `, + animations: [dialogAnimation] +}) +export class SfngDialogContainerComponent { + onStateChange = new Subject(); + + ref: ComponentRef | EmbeddedViewRef | null = null; + + constructor( + private cdr: ChangeDetectorRef, + ) { } + + @HostBinding('@dialogContainer') + state = 'enter'; + + @ViewChild(CdkPortalOutlet, { static: true }) + _portalOutlet: CdkPortalOutlet | null = null; + + @ViewChild(CdkDrag, { static: true }) + drag!: CdkDrag; + + attachComponentPortal(portal: ComponentPortal): ComponentRef { + this.ref = this._portalOutlet!.attachComponentPortal(portal) + return this.ref; + } + + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + this.ref = this._portalOutlet!.attachTemplatePortal(portal); + return this.ref; + } + + @Input() + dragable: boolean = false; + + @HostListener('@dialogContainer.start', ['$event']) + onAnimationStart({ toState }: AnimationEvent) { + if (toState === 'enter') { + this.onStateChange.next('opening'); + } else if (toState === 'exit') { + this.onStateChange.next('closing'); + } + } + + @HostListener('@dialogContainer.done', ['$event']) + onAnimationEnd({ toState }: AnimationEvent) { + if (toState === 'enter') { + this.onStateChange.next('open'); + } else if (toState === 'exit') { + this.onStateChange.next('closed'); + } + } + + /** Starts the exit animation */ + _startExit() { + this.state = 'exit'; + this.cdr.markForCheck(); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.module.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.module.ts new file mode 100644 index 00000000..d47195b9 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.module.ts @@ -0,0 +1,23 @@ +import { DragDropModule } from "@angular/cdk/drag-drop"; +import { OverlayModule } from "@angular/cdk/overlay"; +import { PortalModule } from "@angular/cdk/portal"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { SfngConfirmDialogComponent } from "./confirm.dialog"; +import { SfngDialogContainerComponent } from "./dialog.container"; + +@NgModule({ + imports: [ + CommonModule, + OverlayModule, + PortalModule, + DragDropModule, + FormsModule, + ], + declarations: [ + SfngDialogContainerComponent, + SfngConfirmDialogComponent, + ] +}) +export class SfngDialogModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.ref.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.ref.ts new file mode 100644 index 00000000..145c60ca --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.ref.ts @@ -0,0 +1,62 @@ +import { OverlayRef } from "@angular/cdk/overlay"; +import { InjectionToken } from "@angular/core"; +import { Observable, PartialObserver, Subject } from "rxjs"; +import { filter, take } from "rxjs/operators"; +import { SfngDialogContainerComponent, SfngDialogState } from "./dialog.container"; + +export const SFNG_DIALOG_REF = new InjectionToken>('SfngDialogRef'); + +export class SfngDialogRef { + constructor( + private _overlayRef: OverlayRef, + private container: SfngDialogContainerComponent, + public readonly data: D, + ) { + this.container.onStateChange + .pipe( + filter(state => state === 'closed'), + take(1) + ) + .subscribe(() => { + this._overlayRef.detach(); + this._overlayRef.dispose(); + this.onClose.next(this.value); + this.onClose.complete(); + }); + } + + get onStateChange(): Observable { + return this.container.onStateChange; + } + + + /** + * @returns The overlayref that holds the dialog container. + */ + overlay() { return this._overlayRef } + + /** + * @returns the instance attached to the dialog container + */ + contentRef() { return this.container.ref! } + + /** Value holds the value passed on close() */ + private value: R | null = null; + + /** + * Emits the result of the dialog and closes the overlay. + */ + onClose = new Subject() + + /** onAction only emits if close() is called with action. */ + onAction(action: T, observer: PartialObserver | ((value: T) => void)): this { + (this.onClose.pipe(filter(val => val === action)) as Observable) + .subscribe(observer as any); // typescript does not select the correct type overload here. + return this; + } + + close(result?: R) { + this.value = result || null; + this.container._startExit(); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.service.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.service.ts new file mode 100644 index 00000000..e7b80ffc --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/dialog.service.ts @@ -0,0 +1,154 @@ +import { Overlay, OverlayConfig, OverlayPositionBuilder, PositionStrategy } from '@angular/cdk/overlay'; +import { ComponentPortal, ComponentType, TemplatePortal } from '@angular/cdk/portal'; +import { EmbeddedViewRef, Injectable, Injector } from '@angular/core'; +import { filter, take, takeUntil } from 'rxjs/operators'; +import { ConfirmDialogConfig, CONFIRM_DIALOG_CONFIG, SfngConfirmDialogComponent } from './confirm.dialog'; +import { SfngDialogContainerComponent } from './dialog.container'; +import { SfngDialogModule } from './dialog.module'; +import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref'; + +export interface BaseDialogConfig { + /** whether or not the dialog should close on outside-clicks and ESC */ + autoclose?: boolean; + + /** whether or not a backdrop should be visible */ + backdrop?: boolean | 'light'; + + /** whether or not the dialog should be dragable */ + dragable?: boolean; + + /** + * optional position strategy for the overlay. if omitted, the + * overlay will be centered on the screen + */ + positionStrategy?: PositionStrategy; + + /** + * Optional data for the dialog that is available either via the + * SfngDialogRef for ComponentPortals as an $implicit context value + * for TemplatePortals. + * + * Note, for template portals, data is only set as an $implicit context + * value if it is not yet set in the portal! + */ + data?: any; +} + +export interface ComponentPortalConfig { + injector?: Injector; +} + +@Injectable({ providedIn: SfngDialogModule }) +export class SfngDialogService { + + constructor( + private injector: Injector, + private overlay: Overlay, + ) { } + + position(): OverlayPositionBuilder { + return this.overlay.position(); + } + + create(template: TemplatePortal, opts?: BaseDialogConfig): SfngDialogRef>; + create(target: ComponentType, opts?: BaseDialogConfig & ComponentPortalConfig): SfngDialogRef; + create(target: ComponentType | TemplatePortal, opts: BaseDialogConfig & ComponentPortalConfig = {}): SfngDialogRef { + let position: PositionStrategy = opts?.positionStrategy || this.overlay + .position() + .global() + .centerVertically() + .centerHorizontally(); + + let hasBackdrop = true; + let backdropClass = 'dialog-screen-backdrop'; + if (opts.backdrop !== undefined) { + if (opts.backdrop === false) { + hasBackdrop = false; + } else if (opts.backdrop === 'light') { + backdropClass = 'dialog-screen-backdrop-light'; + } + } + + const cfg = new OverlayConfig({ + scrollStrategy: this.overlay.scrollStrategies.noop(), + positionStrategy: position, + hasBackdrop: hasBackdrop, + backdropClass: backdropClass, + }); + const overlayref = this.overlay.create(cfg); + + // create our dialog container and attach it to the + // overlay. + const containerPortal = new ComponentPortal>( + SfngDialogContainerComponent, + undefined, + this.injector, + ) + const containerRef = containerPortal.attach(overlayref); + + if (!!opts.dragable) { + containerRef.instance.dragable = true; + } + + // create the dialog ref + const dialogRef = new SfngDialogRef(overlayref, containerRef.instance, opts.data); + + // prepare the content portal and attach it to the container + let result: any; + if (target instanceof TemplatePortal) { + let r = containerRef.instance.attachTemplatePortal(target) + + if (!!r.context && typeof r.context === 'object' && !('$implicit' in r.context)) { + r.context = { + $implicit: opts.data, + ...r.context, + } + } + + result = r + } else { + const contentPortal = new ComponentPortal(target, null, Injector.create({ + providers: [ + { + provide: SFNG_DIALOG_REF, + useValue: dialogRef, + } + ], + parent: opts?.injector || this.injector, + })); + result = containerRef.instance.attachComponentPortal(contentPortal); + } + // update the container position now that we have some content. + overlayref.updatePosition(); + + if (!!opts?.autoclose) { + overlayref.outsidePointerEvents() + .pipe(take(1)) + .subscribe(() => dialogRef.close()); + overlayref.keydownEvents() + .pipe( + takeUntil(overlayref.detachments()), + filter(event => event.key === 'Escape') + ) + .subscribe(() => { + dialogRef.close(); + }) + } + return dialogRef; + } + + confirm(opts: ConfirmDialogConfig): SfngDialogRef { + return this.create(SfngConfirmDialogComponent, { + autoclose: opts.canCancel, + injector: Injector.create({ + providers: [ + { + provide: CONFIRM_DIALOG_CONFIG, + useValue: opts, + }, + ], + parent: this.injector, + }) + }) + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dialog/index.ts b/desktop/angular/projects/safing/ui/src/lib/dialog/index.ts new file mode 100644 index 00000000..538cb300 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dialog/index.ts @@ -0,0 +1,5 @@ +export { ConfirmDialogConfig } from './confirm.dialog'; +export * from './dialog.module'; +export * from './dialog.ref'; +export * from './dialog.service'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.html b/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.html new file mode 100644 index 00000000..33232ea0 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.html @@ -0,0 +1,27 @@ +
+ +
+ + + +
+ {{ label }} + + + + +
+
+ + +
+ +
+
diff --git a/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.module.ts b/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.module.ts new file mode 100644 index 00000000..1bfb6846 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.module.ts @@ -0,0 +1,18 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { SfngDropdownComponent } from "./dropdown"; + +@NgModule({ + imports: [ + CommonModule, + OverlayModule, + ], + declarations: [ + SfngDropdownComponent, + ], + exports: [ + SfngDropdownComponent, + ] +}) +export class SfngDropDownModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.ts b/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.ts new file mode 100644 index 00000000..3b50a8f5 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.ts @@ -0,0 +1,216 @@ +import { coerceBooleanProperty, coerceCssPixelValue, coerceNumberProperty } from "@angular/cdk/coercion"; +import { CdkOverlayOrigin, ConnectedPosition, ScrollStrategy, ScrollStrategyOptions } from "@angular/cdk/overlay"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnInit, Output, Renderer2, TemplateRef, ViewChild } from "@angular/core"; +import { fadeInAnimation, fadeOutAnimation } from '../animations'; + +@Component({ + selector: 'sfng-dropdown', + exportAs: 'sfngDropdown', + templateUrl: './dropdown.html', + styles: [ + ` + :host { + display: block; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [fadeInAnimation, fadeOutAnimation], +}) +export class SfngDropdownComponent implements OnInit { + /** The trigger origin used to open the drop-down */ + @ViewChild('trigger', { read: CdkOverlayOrigin }) + trigger: CdkOverlayOrigin | null = null; + + /** + * The button/drop-down label. Only when not using + * {@Link SfngDropdown.externalTrigger} + */ + @Input() + label: string = ''; + + /** The trigger template to use when {@Link SfngDropdown.externalTrigger} */ + @Input() + triggerTemplate: TemplateRef | null = null; + + /** Set to true to provide an external dropdown trigger template using {@Link SfngDropdown.triggerTemplate} */ + @Input() + set externalTrigger(v: any) { + this._externalTrigger = coerceBooleanProperty(v) + } + get externalTrigger() { + return this._externalTrigger; + } + private _externalTrigger = false; + + /** A list of classes to apply to the overlay element */ + @Input() + overlayClass: string = ''; + + /** Whether or not the drop-down is disabled. */ + @Input() + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v) + } + get disabled() { + return this._disabled; + } + private _disabled = false; + + /** The Y-offset of the drop-down overlay */ + @Input() + set offsetY(v: any) { + this._offsetY = coerceNumberProperty(v); + } + get offsetY() { return this._offsetY } + private _offsetY = 4; + + /** The X-offset of the drop-down overlay */ + @Input() + set offsetX(v: any) { + this._offsetX = coerceNumberProperty(v); + } + get offsetX() { return this._offsetX } + private _offsetX = 0; + + /** The scrollStrategy of the drop-down */ + @Input() + scrollStrategy!: ScrollStrategy; + + /** Whether or not the pop-over is currently shown. Do not modify this directly */ + isOpen = false; + + /** The minimum width of the drop-down */ + @Input() + set minWidth(val: any) { + this._minWidth = coerceCssPixelValue(val) + } + get minWidth() { return this._minWidth } + private _minWidth: string | number = 0; + + /** The maximum width of the drop-down */ + @Input() + set maxWidth(val: any) { + this._maxWidth = coerceCssPixelValue(val) + } + get maxWidth() { return this._maxWidth } + private _maxWidth: string | number | null = null; + + /** The minimum height of the drop-down */ + @Input() + set minHeight(val: any) { + this._minHeight = coerceCssPixelValue(val) + } + get minHeight() { return this._minHeight } + private _minHeight: string | number | null = null; + + /** The maximum width of the drop-down */ + @Input() + set maxHeight(val: any) { + this._maxHeight = coerceCssPixelValue(val) + } + get maxHeight() { return this._maxHeight } + private _maxHeight: string | number | null = null; + + /** Emits whenever the drop-down is opened */ + @Output() + opened = new EventEmitter(); + + /** Emits whenever the drop-down is closed. */ + @Output() + closed = new EventEmitter(); + + @Input() + positions: ConnectedPosition[] = [ + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'bottom', + }, + ] + + constructor( + public readonly elementRef: ElementRef, + private changeDetectorRef: ChangeDetectorRef, + private renderer: Renderer2, + private scrollOptions: ScrollStrategyOptions, + ) { + } + + ngOnInit() { + this.scrollStrategy = this.scrollStrategy || this.scrollOptions.close(); + } + + onOutsideClick(event: MouseEvent) { + if (!!this.trigger) { + const triggerEl = this.trigger.elementRef.nativeElement; + + let node = event.target; + while (!!node) { + if (node === triggerEl) { + return; + } + node = this.renderer.parentNode(node); + } + } + + this.close(); + } + + onOverlayClosed() { + this.closed.next(); + } + + close() { + if (!this.isOpen) { + return; + } + + this.isOpen = false; + this.changeDetectorRef.markForCheck(); + } + + toggle(t: CdkOverlayOrigin | null = this.trigger) { + if (this.isOpen) { + this.close(); + + return; + } + + this.show(t); + } + + show(t: CdkOverlayOrigin | null = this.trigger) { + if (t === null) { + return; + } + + if (this.isOpen || this._disabled) { + return; + } + + if (!!t) { + this.trigger = t; + const rect = (this.trigger.elementRef.nativeElement as HTMLElement).getBoundingClientRect() + + this.minWidth = rect ? rect.width : this.trigger.elementRef.nativeElement.offsetWidth; + + } + this.isOpen = true; + this.opened.next(); + this.changeDetectorRef.markForCheck(); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/dropdown/index.ts b/desktop/angular/projects/safing/ui/src/lib/dropdown/index.ts new file mode 100644 index 00000000..ba7a9834 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/dropdown/index.ts @@ -0,0 +1,3 @@ +export * from './dropdown'; +export * from './dropdown.module'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/index.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/index.ts new file mode 100644 index 00000000..8c797446 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/index.ts @@ -0,0 +1,5 @@ +export * from './overlay-stepper'; +export * from './overlay-stepper.module'; +export * from './refs'; +export * from './step'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.html b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.html new file mode 100644 index 00000000..5da1fb3e --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.html @@ -0,0 +1,22 @@ + + + + +
+ +
+ + + + + + +
+ + +
+
diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.ts new file mode 100644 index 00000000..18492f21 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.ts @@ -0,0 +1,261 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { CdkPortalOutlet, ComponentPortal, ComponentType } from "@angular/cdk/portal"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, Injector, isDevMode, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { Subject } from "rxjs"; +import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog"; +import { StepperControl, StepRef, STEP_REF } from "./refs"; +import { Step, StepperConfig } from "./step"; +import { StepOutletComponent, STEP_ANIMATION_DIRECTION, STEP_PORTAL } from "./step-outlet"; + +/** + * STEP_CONFIG is used to inject the StepperConfig into the OverlayStepperContainer. + */ +export const STEP_CONFIG = new InjectionToken('StepperConfig'); + +@Component({ + templateUrl: './overlay-stepper-container.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + position: relative; + display: flex; + flex-direction: column; + width: 600px; + } + ` + ], + animations: [ + trigger( + 'moveInOut', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateX({{ in }})' }), + animate('.2s cubic-bezier(0.4, 0, 0.2, 1)', + style({ opacity: 1, transform: 'translateX(0%)' })) + ], + { params: { in: '100%' } } // default parameters + ), + transition( + ':leave', + [ + style({ opacity: 1 }), + animate('.2s cubic-bezier(0.4, 0, 0.2, 1)', + style({ opacity: 0, transform: 'translateX({{ out }})' })) + ], + { params: { out: '-100%' } } // default parameters + ) + ] + )] +}) +export class OverlayStepperContainerComponent implements OnInit, OnDestroy, StepperControl { + /** Used to keep cache the stepRef instances. See documentation for {@class StepRef} */ + private stepRefCache = new Map(); + + /** Used to emit when the stepper finished. This is always folled by emitting on onClose$ */ + private onFinish$ = new Subject(); + + /** Emits when the stepper finished - also see {@link OverlayStepperContainerComponent.onClose}*/ + get onFinish() { + return this.onFinish$.asObservable(); + } + + /** + * Emits when the stepper is closed. + * If the stepper if finished then onFinish will emit first + */ + get onClose() { + return this.dialogRef.onClose; + } + + /** The index of the currently displayed step */ + currentStepIndex = -1; + + /** The component instance of the current step */ + currentStep: Step | null = null; + + /** A reference to the portalOutlet used to render our steps */ + @ViewChild(CdkPortalOutlet, { static: true }) + portalOutlet!: CdkPortalOutlet; + + /** Whether or not the user can go back */ + canGoBack = false; + + /** Whether or not the user can abort and close the stepper */ + canAbort = false; + + /** Whether the current step is the last step */ + get isLast() { + return this.currentStepIndex + 1 >= this.config.steps.length; + } + + constructor( + @Inject(STEP_CONFIG) public readonly config: StepperConfig, + @Inject(SFNG_DIALOG_REF) public readonly dialogRef: SfngDialogRef, + private injector: Injector, + private cdr: ChangeDetectorRef + ) { } + + /** + * Moves forward to the next step or closes the stepper + * when moving beyond the last one. + */ + next(): Promise { + if (this.isLast) { + this.onFinish$.next(); + this.close(); + + return Promise.resolve(); + } + + return this.attachStep(this.currentStepIndex + 1, true) + } + + /** + * Moves back to the previous step. This does not take canGoBack + * into account. + */ + goBack(): Promise { + return this.attachStep(this.currentStepIndex - 1, false) + } + + + /** Closes the stepper - this does not run the onFinish hooks of the steps */ + async close(): Promise { + this.dialogRef.close(); + } + + ngOnInit(): void { + this.next(); + } + + ngOnDestroy(): void { + this.onFinish$.complete(); + } + + /** + * Attaches a new step component in the current outlet. It detaches any previous + * step and calls onBeforeBack and onBeforeNext respectively. + * + * @param index The index of the new step to attach. + * @param forward Whether or not the new step is attached by going "forward" or "backward" + * @returns + */ + private async attachStep(index: number, forward = true) { + if (index >= this.config.steps.length) { + if (isDevMode()) { + throw new Error(`Cannot attach step at ${index}: index out of range`) + } + return; + } + + // call onBeforeNext or onBeforeBack of the current step + if (this.currentStep) { + if (forward) { + if (!!this.currentStep.onBeforeNext) { + try { + await this.currentStep.onBeforeNext(); + } catch (err) { + console.error(`Failed to move to next step`, err) + // TODO(ppacher): display error + + return; + } + } + } else { + if (!!this.currentStep.onBeforeBack) { + try { + await this.currentStep.onBeforeBack() + } catch (err) { + console.error(`Step onBeforeBack callback failed`, err) + } + } + } + + // detach the current step component. + this.portalOutlet.detach(); + } + + const stepType = this.config.steps[index]; + const contentPortal = this.createStepContentPortal(stepType, index) + const outletPortal = this.createStepOutletPortal(contentPortal, forward ? 'right' : 'left') + + // attach the new step (which is wrapped in a StepOutletComponent). + const ref = this.portalOutlet.attachComponentPortal(outletPortal); + + // We need to wait for the step to be actually attached in the outlet + // to get access to the actual step component instance. + ref.instance.portalOutlet!.attached + .subscribe((stepRef: ComponentRef) => { + this.currentStep = stepRef.instance; + this.currentStepIndex = index; + + if (typeof this.config.canAbort === 'function') { + this.canAbort = this.config.canAbort(this.currentStepIndex, this.currentStep); + } + + // make sure we trigger a change-detection cycle now + // markForCheck() is not enough here as we need a CD to run + // immediately for the Step.buttonTemplate to be accounted for correctly. + this.cdr.detectChanges(); + }) + } + + /** + * Creates a new component portal for a step and provides access to the {@class StepRef} + * using dependency injection. + * + * @param stepType The component type of the step for which a new portal should be created. + * @param index The index of the current step. Used to create/cache the {@class StepRef} + */ + private createStepContentPortal(stepType: ComponentType, index: number): ComponentPortal { + let stepRef = this.stepRefCache.get(index); + if (stepRef === undefined) { + stepRef = new StepRef(index, this) + this.stepRefCache.set(index, stepRef); + } + + const injector = Injector.create({ + providers: [ + { + provide: STEP_REF, + useValue: stepRef, + } + ], + parent: this.config.injector || this.injector, + }) + + return new ComponentPortal(stepType, undefined, injector); + } + + /** + * Creates a new component portal for a step outlet component that will attach another content + * portal and wrap the attachment in a "move in" animation for a given direction. + * + * @param contentPortal The portal of the actual content that should be attached in the outlet + * @param dir The direction for the animation of the step outlet. + */ + private createStepOutletPortal(contentPortal: ComponentPortal, dir: 'left' | 'right'): ComponentPortal { + const injector = Injector.create({ + providers: [ + { + provide: STEP_PORTAL, + useValue: contentPortal, + }, + { + provide: STEP_ANIMATION_DIRECTION, + useValue: dir, + }, + ], + parent: this.injector, + }) + + return new ComponentPortal( + StepOutletComponent, + undefined, + injector, + ) + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.module.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.module.ts new file mode 100644 index 00000000..6bf5fa63 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.module.ts @@ -0,0 +1,21 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { PortalModule } from "@angular/cdk/portal"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { SfngDialogModule } from "../dialog"; +import { OverlayStepperContainerComponent } from "./overlay-stepper-container"; +import { StepOutletComponent } from "./step-outlet"; + +@NgModule({ + imports: [ + CommonModule, + PortalModule, + OverlayModule, + SfngDialogModule, + ], + declarations: [ + OverlayStepperContainerComponent, + StepOutletComponent, + ] +}) +export class OverlayStepperModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.ts new file mode 100644 index 00000000..4795777a --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.ts @@ -0,0 +1,57 @@ +import { ComponentRef, Injectable, Injector } from "@angular/core"; +import { SfngDialogService } from "../dialog"; +import { OverlayStepperContainerComponent, STEP_CONFIG } from "./overlay-stepper-container"; +import { OverlayStepperModule } from "./overlay-stepper.module"; +import { StepperRef } from "./refs"; +import { StepperConfig } from "./step"; + +@Injectable({ providedIn: OverlayStepperModule }) +export class OverlayStepper { + constructor( + private injector: Injector, + private dialog: SfngDialogService, + ) { } + + /** + * Creates a new overlay stepper given it's configuration and returns + * a reference to the stepper that can be used to wait for or control + * the stepper from outside. + * + * @param config The configuration for the overlay stepper. + */ + create(config: StepperConfig): StepperRef { + // create a new injector for our OverlayStepperContainer + // that holds a reference to the StepperConfig. + const injector = this.createInjector(config); + + const dialogRef = this.dialog.create(OverlayStepperContainerComponent, { + injector: injector, + autoclose: false, + backdrop: 'light', + dragable: false, + }) + + const containerComponentRef = dialogRef.contentRef() as ComponentRef; + + return new StepperRef(containerComponentRef.instance); + } + + /** + * Creates a new dependency injector that provides access to the + * stepper configuration using the STEP_CONFIG injection token. + * + * @param config The stepper configuration to provide using DI + * @returns + */ + private createInjector(config: StepperConfig): Injector { + return Injector.create({ + providers: [ + { + provide: STEP_CONFIG, + useValue: config, + }, + ], + parent: this.injector, + }) + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/refs.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/refs.ts new file mode 100644 index 00000000..c5ce4433 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/refs.ts @@ -0,0 +1,143 @@ +import { InjectionToken } from "@angular/core"; +import { Observable } from "rxjs"; +import { take } from "rxjs/operators"; +import { OverlayStepperContainerComponent } from "./overlay-stepper-container"; + +/** + * STEP_REF is the injection token that is used to provide a reference to the + * Stepper to each step. + */ +export const STEP_REF = new InjectionToken>('StepRef') + +export interface StepperControl { + /** + * Next should move the stepper forward to the next + * step or close the stepper if no more steps are + * available. + * If the stepper is closed this way all onFinish hooks + * registered at {@link StepRef} are executed. + */ + next(): Promise; + + /** + * goBack should move the stepper back to the previous + * step. This is a no-op if there's no previous step to + * display. + */ + goBack(): Promise; + + /** + * close closes the stepper but does not run any onFinish hooks + * of {@link StepRef}. + */ + close(): Promise; +} + +/** + * StepRef is a reference to the overlay stepper and can be used to control, abort + * or otherwise interact with the stepper. + * + * It is made available to individual steps using the STEP_REF injection token. + * Each step in the OverlayStepper receives it's own StepRef instance and will receive + * a reference to the same instance in case the user goes back and re-opens a step + * again. + * + * Steps should therefore store any configuration data that is needed to restore + * the previous view in the StepRef using it's save() and load() methods. + */ +export class StepRef implements StepperControl { + private onFinishHooks: (() => PromiseLike | void)[] = []; + private data: T | null = null; + + constructor( + private currentStepIndex: number, + private stepContainerRef: OverlayStepperContainerComponent, + ) { + this.stepContainerRef.onFinish + .pipe(take(1)) + .subscribe(() => this.runOnFinishHooks) + } + + next(): Promise { + return this.stepContainerRef.next(); + } + + goBack(): Promise { + return this.stepContainerRef.goBack(); + } + + close(): Promise { + return this.stepContainerRef.close(); + } + + /** + * Save saves data of the current step in the stepper session. + * This data is saved in case the user decides to "go back" to + * to a previous step so the old view can be restored. + * + * @param data The data to save in the stepper session. + */ + save(data: T): void { + this.data = data; + } + + /** + * Load returns the data previously stored using save(). The + * StepperRef automatically makes sure the correct data is returned + * for the current step. + */ + load(): T | null { + return this.data; + } + + /** + * registerOnFinish registers fn to be called when the last step + * completes and the stepper is going to finish. + */ + registerOnFinish(fn: () => PromiseLike | void) { + this.onFinishHooks.push(fn); + } + + /** + * Executes all onFinishHooks in the order they have been defined + * and waits for each hook to complete. + */ + private async runOnFinishHooks() { + for (let i = 0; i < this.onFinishHooks.length; i++) { + let res = this.onFinishHooks[i](); + if (typeof res === 'object' && 'then' in res) { + // res is a PromiseLike so wait for it + try { + await res; + } catch (err) { + console.error(`Failed to execute on-finish hook of step ${this.currentStepIndex}: `, err) + } + } + } + } +} + + +export class StepperRef implements StepperControl { + constructor(private stepContainerRef: OverlayStepperContainerComponent) { } + + next(): Promise { + return this.stepContainerRef.next(); + } + + goBack(): Promise { + return this.stepContainerRef.goBack(); + } + + close(): Promise { + return this.stepContainerRef.close(); + } + + get onFinish(): Observable { + return this.stepContainerRef.onFinish; + } + + get onClose(): Observable { + return this.stepContainerRef.onClose; + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step-outlet.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step-outlet.ts new file mode 100644 index 00000000..75bfab61 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step-outlet.ts @@ -0,0 +1,90 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal"; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, ViewChild } from "@angular/core"; +import { Step } from "./step"; + +export const STEP_PORTAL = new InjectionToken>('STEP_PORTAL') +export const STEP_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('STEP_ANIMATION_DIRECTION'); + +/** + * A simple wrapper component around CdkPortalOutlet to add nice + * move animations. + */ +@Component({ + template: ` +
+ +
+ `, + styles: [ + ` + :host{ + display: flex; + flex-direction: column; + overflow: hidden; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger( + 'moveInOut', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateX({{ in }})' }), + animate('.2s ease-in', + style({ opacity: 1, transform: 'translateX(0%)' })) + ], + { params: { in: '100%' } } // default parameters + ), + transition( + ':leave', + [ + style({ opacity: 1 }), + animate('.2s ease-out', + style({ opacity: 0, transform: 'translateX({{ out }})' })) + ], + { params: { out: '-100%' } } // default parameters + ) + ] + )] +}) +export class StepOutletComponent implements AfterViewInit { + /** @private - Whether or not the animation should run. */ + _appAnimate = false; + + /** The actual step instance that has been attached. */ + stepInstance: ComponentRef | null = null; + + /** @private - used in animation interpolation for translateX */ + get in() { + return this._animateDirection == 'left' ? '-100%' : '100%' + } + + /** @private - used in animation interpolation for traslateX */ + get out() { + return this._animateDirection == 'left' ? '100%' : '-100%' + } + + /** The portal outlet in our view used to attach the step */ + @ViewChild(CdkPortalOutlet, { static: true }) + portalOutlet!: CdkPortalOutlet; + + constructor( + @Inject(STEP_PORTAL) public portal: ComponentPortal, + @Inject(STEP_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right', + private cdr: ChangeDetectorRef + ) { } + + ngAfterViewInit(): void { + this.portalOutlet?.attached + .subscribe(ref => { + this.stepInstance = ref as ComponentRef; + + this._appAnimate = true; + this.cdr.detectChanges(); + }) + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step.ts b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step.ts new file mode 100644 index 00000000..1611ff15 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step.ts @@ -0,0 +1,64 @@ +import { Injector, TemplateRef, Type } from "@angular/core"; +import { Observable } from "rxjs"; + +export interface Step { + /** + * validChange should emit true or false when the current step + * is valid and the "next" button should be visible. + */ + validChange: Observable; + + /** + * onBeforeBack, if it exists, is called when the user + * clicks the "Go Back" button but before the current step + * is unloaded. + * + * The OverlayStepper will wait for the callback to resolve or + * reject but will not abort going back! + */ + onBeforeBack?: () => Promise; + + /** + * onBeforeNext, if it exists, is called when the user + * clicks the "Next" button but before the current step + * is unloaded. + * + * The OverlayStepper willw ait for the callback to resolve + * or reject. If it rejects the current step will not be unloaded + * and the rejected error will be displayed to the user. + */ + onBeforeNext?: () => Promise; + + /** + * nextButtonLabel can overwrite the label for the "Next" button. + */ + nextButtonLabel?: string; + + /** + * buttonTemplate may hold a tempalte ref that is rendered instead + * of the default button row with a "Go Back" and a "Next" button. + * Note that if set, the step component must make sure to handle + * navigation itself. See {@class StepRef} for more information on how + * to control the stepper. + */ + buttonTemplate?: TemplateRef; +} + +export interface StepperConfig { + /** + * canAbort can be set to a function that is called + * for each step to determine if the stepper is abortable. + */ + canAbort?: (idx: number, step: Step) => boolean; + + /** steps holds the list of steps to execute */ + steps: Array> + + /** + * injector, if set, defines the parent injector used to + * create dedicated instances of the step types. + */ + injector?: Injector; +} + + diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/_pagination.scss b/desktop/angular/projects/safing/ui/src/lib/pagination/_pagination.scss new file mode 100644 index 00000000..46b8bdaf --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/_pagination.scss @@ -0,0 +1,22 @@ +sfng-pagination { + .pagination { + @apply my-2 w-full flex justify-between; + + button { + @apply text-xxs px-2 flex items-center justify-start; + + &.page { + @apply bg-cards-secondary; + @apply opacity-50; + + &:hover { + @apply opacity-100; + } + } + + &.active-page { + @apply text-blue font-medium opacity-100; + } + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/dynamic-items-paginator.ts b/desktop/angular/projects/safing/ui/src/lib/pagination/dynamic-items-paginator.ts new file mode 100644 index 00000000..b3f8a833 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/dynamic-items-paginator.ts @@ -0,0 +1,64 @@ + +import { BehaviorSubject, Observable, Subscription } from "rxjs"; +import { Pagination, clipPage } from "./pagination"; + +export interface Datasource { + // view should emit all items in the given page using the specified page number. + view(page: number, pageSize: number): Observable; +} + +export class DynamicItemsPaginator implements Pagination { + private _total = 0; + private _pageNumber$ = new BehaviorSubject(1); + private _pageItems$ = new BehaviorSubject([]); + private _pageLoading$ = new BehaviorSubject(false); + private _pageSubscription = Subscription.EMPTY; + + /** Returns the number of total pages. */ + get total() { return this._total; } + + /** Emits the current page number */ + get pageNumber$() { return this._pageNumber$.asObservable() } + + /** Emits all items of the current page */ + get pageItems$() { return this._pageItems$.asObservable() } + + /** Emits whether or not we're loading the next page */ + get pageLoading$() { return this._pageLoading$.asObservable() } + + constructor( + private source: Datasource, + public readonly pageSize = 25, + ) { } + + reset(newTotal: number) { + this._total = Math.ceil(newTotal / this.pageSize); + this.openPage(1); + } + + /** Clear resets the current total and emits an empty item set. */ + clear() { + this._total = 0; + this._pageItems$.next([]); + this._pageNumber$.next(1); + this._pageSubscription.unsubscribe(); + } + + openPage(pageNumber: number): void { + pageNumber = clipPage(pageNumber, this.total); + this._pageLoading$.next(true); + + this._pageSubscription.unsubscribe() + this._pageSubscription = this.source.view(pageNumber, this.pageSize) + .subscribe({ + next: results => { + this._pageLoading$.next(false); + this._pageItems$.next(results); + this._pageNumber$.next(pageNumber); + } + }); + } + + nextPage(): void { this.openPage(this._pageNumber$.getValue() + 1) } + prevPage(): void { this.openPage(this._pageNumber$.getValue() - 1) } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/index.ts b/desktop/angular/projects/safing/ui/src/lib/pagination/index.ts new file mode 100644 index 00000000..fb2f898c --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/index.ts @@ -0,0 +1,5 @@ +export * from './dynamic-items-paginator'; +export * from './pagination'; +export * from './pagination.module'; +export * from './snapshot-paginator'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.html b/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.html new file mode 100644 index 00000000..dec63df8 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.module.ts b/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.module.ts new file mode 100644 index 00000000..508454ca --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.module.ts @@ -0,0 +1,19 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { SfngPaginationContentDirective } from "."; +import { SfngPaginationWrapperComponent } from "./pagination"; + +@NgModule({ + imports: [ + CommonModule, + ], + declarations: [ + SfngPaginationContentDirective, + SfngPaginationWrapperComponent, + ], + exports: [ + SfngPaginationContentDirective, + SfngPaginationWrapperComponent, + ], +}) +export class SfngPaginationModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.ts b/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.ts new file mode 100644 index 00000000..f3241cc9 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/pagination.ts @@ -0,0 +1,132 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Directive, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, TemplateRef } from "@angular/core"; +import { Observable, Subscription } from "rxjs"; + +export interface Pagination { + /** + * Total should return the total number of pages + */ + total: number; + + /** + * pageNumber$ should emit the currently displayed page + */ + pageNumber$: Observable; + + /** + * pageItems$ should emit all items of the current page + */ + pageItems$: Observable; + + /** + * nextPage should progress to the next page. If there are no more + * pages than nextPage() should be a no-op. + */ + nextPage(): void; + + /** + * prevPage should move back the the previous page. If there is no + * previous page, prevPage should be a no-op. + */ + prevPage(): void; + + /** + * openPage opens the page @pageNumber. If pageNumber is greater than + * the total amount of pages it is clipped to the lastPage. If it is + * less than 1, it is clipped to 1. + */ + openPage(pageNumber: number): void +} + + + +@Directive({ + selector: '[sfngPageContent]' +}) +export class SfngPaginationContentDirective { + constructor(public readonly templateRef: TemplateRef) { } +} + +export interface PageChangeEvent { + totalPages: number; + currentPage: number; +} + +@Component({ + selector: 'sfng-pagination', + templateUrl: './pagination.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngPaginationWrapperComponent implements OnChanges, OnDestroy { + private _sub: Subscription = Subscription.EMPTY; + + @Input() + source: Pagination | null = null; + + @Output() + pageChange = new EventEmitter(); + + @ContentChild(SfngPaginationContentDirective) + content: SfngPaginationContentDirective | null = null; + + currentPageIdx: number = 0; + pageNumbers: number[] = []; + + ngOnChanges(changes: SimpleChanges) { + if ('source' in changes) { + this.subscribeToSource(changes.source.currentValue); + } + } + + ngOnDestroy() { + this._sub.unsubscribe(); + } + + private subscribeToSource(source: Pagination) { + // Unsubscribe from the previous pagination, if any + this._sub.unsubscribe(); + + this._sub = new Subscription(); + + this._sub.add( + source.pageNumber$ + .subscribe(current => { + this.currentPageIdx = current; + this.pageNumbers = generatePageNumbers(current - 1, source.total); + this.cdr.markForCheck(); + + this.pageChange.next({ + totalPages: source.total, + currentPage: current, + }) + }) + ) + } + + constructor(private cdr: ChangeDetectorRef) { } +} + +/** + * Generates an array of page numbers that should be displayed in paginations. + * + * @param current The current page number + * @param countPages The total number of pages + * @returns An array of page numbers to display + */ +export function generatePageNumbers(current: number, countPages: number): number[] { + let delta = 2; + let leftRange = current - delta; + let rightRange = current + delta + 1; + + return Array.from({ length: countPages }, (v, k) => k + 1) + .filter(i => i === 1 || i === countPages || (i >= leftRange && i < rightRange)); +} + +export function clipPage(pageNumber: number, total: number): number { + if (pageNumber < 1) { + return 1; + } + if (pageNumber > total) { + return total; + } + return pageNumber; +} diff --git a/desktop/angular/projects/safing/ui/src/lib/pagination/snapshot-paginator.ts b/desktop/angular/projects/safing/ui/src/lib/pagination/snapshot-paginator.ts new file mode 100644 index 00000000..7f014254 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/pagination/snapshot-paginator.ts @@ -0,0 +1,64 @@ +import { BehaviorSubject, Observable } from "rxjs"; +import { debounceTime, map } from "rxjs/operators"; +import { clipPage, Pagination } from "./pagination"; + +export class SnapshotPaginator implements Pagination { + private _itemSnapshot: T[] = []; + private _activePageItems = new BehaviorSubject([]); + private _totalPages = 1; + private _updatePending = false; + + constructor( + public items$: Observable, + public readonly pageSize: number, + ) { + items$ + .pipe(debounceTime(100)) + .subscribe(data => { + this._itemSnapshot = data; + this.openPage(this._currentPage.getValue()); + }); + + this._currentPage + .subscribe(page => { + this._updatePending = false; + const start = this.pageSize * (page - 1); + const end = this.pageSize * page; + this._totalPages = Math.ceil(this._itemSnapshot.length / this.pageSize) || 1; + this._activePageItems.next(this._itemSnapshot.slice(start, end)); + }) + } + + private _currentPage = new BehaviorSubject(0); + + get updatePending() { + return this._updatePending; + } + get pageNumber$(): Observable { + return this._activePageItems.pipe(map(() => this._currentPage.getValue())); + } + get pageNumber(): number { + return this._currentPage.getValue(); + } + get total(): number { + return this._totalPages + } + get pageItems$(): Observable { + return this._activePageItems.asObservable(); + } + get pageItems(): T[] { + return this._activePageItems.getValue(); + } + get snapshot(): T[] { return this._itemSnapshot }; + + reload(): void { this.openPage(this._currentPage.getValue()) } + + nextPage(): void { this.openPage(this._currentPage.getValue() + 1) } + + prevPage(): void { this.openPage(this._currentPage.getValue() - 1) } + + openPage(pageNumber: number): void { + pageNumber = clipPage(pageNumber, this.total); + this._currentPage.next(pageNumber); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/select/_select.scss b/desktop/angular/projects/safing/ui/src/lib/select/_select.scss new file mode 100644 index 00000000..0d8cb345 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/select/_select.scss @@ -0,0 +1,73 @@ +.sfng-select { + @apply cursor-pointer relative p-0 flex whitespace-nowrap w-full items-center outline-none self-center overflow-hidden; + @apply hover:bg-gray-400; + @apply bg-gray-300 border border-gray-300 transition ease-in-out duration-200; + + &.disabled { + @apply cursor-not-allowed opacity-75 hover:bg-gray-400; + } + + min-width: 6rem; + max-width: 12rem; + + &.active { + @apply bg-gray-400; + + div.arrow svg { + @apply transform -rotate-90; + } + } + + & > span { + @apply flex-grow text-ellipsis inline-block overflow-hidden; + @apply px-2; + } + + div.arrow { + @apply flex flex-row items-center justify-center bg-gray-200 rounded-r-sm; + @apply w-5 h-7; + + svg { + @apply w-4 m-0 p-0 rotate-90 transform transition ease-in-out duration-100; + + g { + @apply text-white; + stroke: currentColor; + } + } + } +} + +.sfng-select-dropdown { + ul { + max-height: 12rem; + @apply relative py-1 overflow-auto; + + li { + @apply py-2; + @apply flex flex-row items-center justify-start gap-1 transition duration-200 ease-in-out cursor-pointer hover:bg-gray-300; + } + + li:not(.disabled) { + @apply hover:bg-gray-300; + } + + li.disabled { + @apply cursor-not-allowed; + } + } +} + +.sfng-select-dropdown.sfng-select-inline { + ul { + max-height: unset; + } +} + +sfng-select-item { + @apply text-xxs w-full font-medium gap-3 text-primary flex flex-row items-center justify-start; + + &.disabled { + @apply opacity-75 cursor-not-allowed; + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/select/index.ts b/desktop/angular/projects/safing/ui/src/lib/select/index.ts new file mode 100644 index 00000000..1342a276 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/select/index.ts @@ -0,0 +1,4 @@ +export * from './item'; +export * from './select'; +export * from './select.module'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/select/item.ts b/desktop/angular/projects/safing/ui/src/lib/select/item.ts new file mode 100644 index 00000000..b2eb5696 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/select/item.ts @@ -0,0 +1,64 @@ +import { ListKeyManagerOption } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Component, Directive, HostBinding, Input, Optional, TemplateRef } from '@angular/core'; + +export interface SelectOption extends ListKeyManagerOption { + value: any; + selected: boolean; + + data?: T; + label?: string; + description?: string; + templateRef?: TemplateRef; + disabled?: boolean; +} + +@Component({ + selector: 'sfng-select-item', + template: ``, +}) +export class SfngSelectItemComponent implements ListKeyManagerOption { + @HostBinding('class.disabled') + get disabled() { + return this.sfngSelectValue?.disabled || false; + } + + getLabel() { + return this.sfngSelectValue?.label || ''; + } + + constructor(@Optional() private sfngSelectValue: SfngSelectValueDirective) { } +} + +@Directive({ + selector: '[sfngSelectValue]', +}) +export class SfngSelectValueDirective implements SelectOption { + @Input('sfngSelectValue') + value: any; + + @Input('sfngSelectValueLabel') + label?: string; + + @Input('sfngSelectValueData') + data?: T; + + @Input('sfngSelectValueDescription') + description = ''; + + @Input('sfngSelectValueDisabled') + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v) + } + get disabled() { return this._disabled } + private _disabled = false; + + getLabel() { + return this.label || ('' + this.value); + } + + /** Whether or not the item is currently selected */ + selected = false; + + constructor(public templateRef: TemplateRef) { } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/select/select.html b/desktop/angular/projects/safing/ui/src/lib/select/select.html new file mode 100644 index 00000000..c3d144eb --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/select/select.html @@ -0,0 +1,88 @@ + + + + + + + +
    +
  • + + + + + + + +
  • + + +
  • + + + Add {{ searchText }} + + +
  • +
+
+ + + + + + + +
+ +
+
+ + + + {{ data.label || data.value }} + diff --git a/desktop/angular/projects/safing/ui/src/lib/select/select.module.ts b/desktop/angular/projects/safing/ui/src/lib/select/select.module.ts new file mode 100644 index 00000000..d33fce4d --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/select/select.module.ts @@ -0,0 +1,31 @@ +import { CdkScrollableModule } from "@angular/cdk/scrolling"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { SfngDropDownModule } from "../dropdown"; +import { SfngTooltipModule } from "../tooltip"; +import { SfngSelectItemComponent, SfngSelectValueDirective } from "./item"; +import { SfngSelectComponent, SfngSelectRenderedItemDirective } from "./select"; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + SfngDropDownModule, + SfngTooltipModule, + CdkScrollableModule + ], + declarations: [ + SfngSelectComponent, + SfngSelectValueDirective, + SfngSelectItemComponent, + SfngSelectRenderedItemDirective + ], + exports: [ + SfngSelectComponent, + SfngSelectValueDirective, + SfngSelectItemComponent, + ] +}) +export class SfngSelectModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/select/select.ts b/desktop/angular/projects/safing/ui/src/lib/select/select.ts new file mode 100644 index 00000000..9375f21f --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/select/select.ts @@ -0,0 +1,495 @@ +import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y'; +import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, DestroyRef, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, OnDestroy, Output, QueryList, TemplateRef, ViewChild, ViewChildren, forwardRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { startWith } from 'rxjs/operators'; +import { SfngDropdownComponent } from '../dropdown'; +import { SelectOption, SfngSelectValueDirective } from './item'; + + +export type SelectModes = 'single' | 'multi'; + +type ModeInput = { + mode: SelectModes; +} + +type SelectValue = S['mode'] extends 'single' ? T : T[]; + +export type SortByFunc = (a: SelectOption, b: SelectOption) => number; + +export type SelectDisplayMode = 'dropdown' | 'inline'; + +@Directive({ + selector: '[sfngSelectRenderedListItem]' +}) +export class SfngSelectRenderedItemDirective implements ListKeyManagerOption { + @Input('sfngSelectRenderedListItem') + option: SelectOption | null = null; + + getLabel() { + return this.option?.label || ''; + } + + get disabled() { + return this.option?.disabled || false; + } + + @HostBinding('class.bg-gray-300') + set focused(v: boolean) { + this._focused = v; + } + get focused() { return this._focused } + private _focused = false; + + constructor(public readonly elementRef: ElementRef) { } +} + +@Component({ + selector: 'sfng-select', + templateUrl: './select.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SfngSelectComponent), + multi: true, + }, + ] +}) +export class SfngSelectComponent implements AfterViewInit, ControlValueAccessor, OnDestroy { + /** emits the search text entered by the user */ + private search$ = new BehaviorSubject(''); + + /** emits and completes when the component is destroyed. */ + private destroyRef = inject(DestroyRef); + + /** the key manager used for keyboard support */ + private keyManager!: ListKeyManager; + + @ViewChild(SfngDropdownComponent, { static: false }) + dropdown: SfngDropdownComponent | null = null; + + /** A reference to the cdk-scrollable directive that's placed on the item list */ + @ViewChild('scrollable', { read: ElementRef }) + scrollableList?: ElementRef; + + @ContentChildren(SfngSelectValueDirective) + userProvidedItems!: QueryList; + + @ViewChildren('renderedItem', { read: SfngSelectRenderedItemDirective }) + renderedItems!: QueryList; + + /** A list of all items available in the select box including dynamic ones. */ + allItems: SelectOption[] = [] + + /** The acutally rendered list of items after applying search and item threshold */ + items: SelectOption[] = []; + + @Input() + @HostBinding('attr.tabindex') + readonly tabindex = 0; + + @HostBinding('attr.role') + readonly role = 'listbox'; + + value?: SelectValue; + + /** A list of currently selected items */ + currentItems: SelectOption[] = []; + + /** The current search text. Used by ngModel */ + searchText = ''; + + /** Whether or not the select operates in "single" or "multi" mode */ + @Input() + mode: SelectModes = 'single'; + + @Input() + displayMode: SelectDisplayMode = 'dropdown'; + + /** The placehodler to show when nothing is selected */ + @Input() + placeholder = 'Select' + + /** The type of item to show in multi mode when more than one value is selected */ + @Input() + itemName = ''; + + /** The maximum number of items to render. */ + @Input() + set itemLimit(v: any) { + this._maxItemLimit = coerceNumberProperty(v) + } + get itemLimit(): number { return this._maxItemLimit } + private _maxItemLimit = Infinity; + + /** The placeholder text for the search bar */ + @Input() + searchPlaceholder = ''; + + /** Whether or not the search bar is visible */ + @Input() + set allowSearch(v: any) { + this._allowSearch = coerceBooleanProperty(v); + } + get allowSearch(): boolean { + return this._allowSearch; + } + private _allowSearch = false; + + /** The minimum number of items required for the search bar to be visible */ + @Input() + set searchItemThreshold(v: any) { + this._searchItemThreshold = coerceNumberProperty(v); + } + get searchItemThreshold(): number { + return this._searchItemThreshold; + } + private _searchItemThreshold = 0; + + /** + * Whether or not the select should be disabled when not options + * are available. + */ + @Input() + set disableWhenEmpty(v: any) { + this._disableWhenEmpty = coerceBooleanProperty(v); + } + get disableWhenEmpty() { + return this._disableWhenEmpty; + } + private _disableWhenEmpty = false; + + /** Whether or not the select component will add options for dynamic values as well. */ + @Input() + set dynamicValues(v: any) { + this._dynamicValues = coerceBooleanProperty(v); + } + get dynamicValues() { + return this._dynamicValues + } + private _dynamicValues = false; + + /** An optional template to use for dynamic values. */ + @Input() + dynamicValueTemplate?: TemplateRef; + + /** The minimum-width of the drop-down. See {@link SfngDropdownComponent.minWidth} */ + @Input() + minWidth: any; + + /** The minimum-width of the drop-down. See {@link SfngDropdownComponent.minHeight} */ + @Input() + minHeight: any; + + /** Whether or not selected items should be sorted to the top */ + @Input() + set sortValues(v: any) { + this._sortValues = coerceBooleanProperty(v); + } + get sortValues() { + if (this._sortValues === null) { + return this.mode === 'multi'; + } + return this._sortValues; + } + private _sortValues: boolean | null = null; + + /** The sort function to use. Defaults to sort by label/value */ + @Input() + sortBy: SortByFunc = (a: SelectOption, b: SelectOption) => { + if ((a.label || a.value) < (b.label || b.value)) { + return 1; + } + if ((a.label || a.value) > (b.label || b.value)) { + return -1; + } + + return 0; + } + + @Input() + set disabled(v: any) { + const disabled = coerceBooleanProperty(v); + this.setDisabledState(disabled); + } + get disabled() { + return this._disabled; + } + private _disabled: boolean = false; + + @HostListener('keydown.enter', ['$event']) + @HostListener('keydown.space', ['$event']) + onEnter(event: Event) { + if (!this.dropdown?.isOpen) { + this.dropdown?.toggle() + + event.preventDefault(); + event.stopPropagation(); + + return; + } + + if (this.keyManager.activeItem !== null && !!this.keyManager.activeItem?.option) { + this.selectItem(this.keyManager.activeItem.option) + + event.preventDefault(); + event.stopPropagation(); + + return; + } + } + + @HostListener('keydown', ['$event']) + onKeyDown(event: KeyboardEvent) { + this.keyManager.onKeydown(event); + } + + @Output() + closed = new EventEmitter(); + + @Output() + opened = new EventEmitter(); + + trackItem(_: number, item: SelectOption) { + return item.value; + } + + setDisabledState(disabled: boolean) { + this._disabled = disabled; + this.cdr.markForCheck(); + } + + constructor(private cdr: ChangeDetectorRef) { } + + ngAfterViewInit(): void { + this.keyManager = new ListKeyManager(this.renderedItems) + .withVerticalOrientation() + .withHomeAndEnd() + .withWrap() + .withTypeAhead(); + + this.keyManager.change + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(itemIdx => { + this.renderedItems.forEach(item => { + item.focused = false; + }) + + this.keyManager.activeItem!.focused = true; + + // the item might be out-of-view so make sure + // we scroll enough to have it inside the view + const scrollable = this.scrollableList?.nativeElement; + if (!!scrollable) { + const active = this.keyManager.activeItem!.elementRef.nativeElement; + const activeHeight = active.getBoundingClientRect().height; + const bottom = scrollable.scrollTop + scrollable.getBoundingClientRect().height; + const top = scrollable.scrollTop; + + let scrollTo = -1; + if (active.offsetTop >= bottom) { + scrollTo = top + active.offsetTop - bottom + activeHeight; + } else if (active.offsetTop < top) { + scrollTo = active.offsetTop; + } + + if (scrollTo > -1) { + scrollable.scrollTo({ + behavior: 'smooth', + top: scrollTo, + }) + } + } + + this.cdr.markForCheck(); + }) + + + combineLatest([ + this.userProvidedItems!.changes + .pipe(startWith(undefined)), + this.search$ + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe( + ([_, search]) => { + this.updateItems(); + + search = (search || '').toLocaleLowerCase() + let items: SelectOption[] = []; + if (search === '') { + items = this.allItems!; + } else { + items = this.allItems!.filter(item => { + // we always count selected items as a "match" in search mode. + // this is to ensure the user always see all selected items. + if (item.selected) { + return true; + } + + if (!!item.value && typeof item.value === 'string') { + if (item.value.toLocaleLowerCase().includes(search)) { + return true; + } + } + + if (!!item.label) { + if (item.label.toLocaleLowerCase().includes(search)) { + return true + } + } + return false; + }) + } + + this.items = items.slice(0, this._maxItemLimit); + this.keyManager.setActiveItem(0); + + this.cdr.detectChanges(); + } + ); + } + + ngOnDestroy(): void { + this.search$.complete(); + } + + @HostListener('blur') + onBlur(): void { + this.onTouch(); + } + + /** @private - called when the internal dropdown opens */ + onDropdownOpen() { + // emit the open event on this component as well + this.opened.next(); + + // reset the search. We do that when opened instead of closed + // to avoid flickering when the component height increases + // during the "close" animation + this.onSearch(''); + } + + /** @private - called when the internal dropdown closes */ + onDropdownClose() { + this.closed.next(); + } + + onSearch(text: string) { + this.searchText = text; + this.search$.next(text); + } + + selectItem(item: SelectOption) { + if (item.disabled) { + return; + } + + const isSelected = this.currentItems.findIndex(selected => item.value === selected.value); + if (isSelected === -1) { + item.selected = true; + + if (this.mode === 'single') { + this.currentItems.forEach(i => i.selected = false); + this.currentItems = [item]; + this.value = item.value; + } else { + this.currentItems.push(item); + // TODO(ppacher): somehow typescript does not correctly pick up + // the type of this.value here although it can be infered from the + // mode === 'single' check above. + this.value = [ + ...(this.value || []) as any, + item.value, + ] as any + } + } else if (this.mode !== 'single') { // "unselecting" a value is not allowed in single mode + this.currentItems.splice(isSelected, 1) + item.selected = false; + // same note about typescript as above. + this.value = (this.value as T[]).filter(val => val !== item.value) as any; + } + + // only close the drop down in single mode. In multi-mode + // we keep it open as the user might want to select an additional + // item as well. + if (this.mode === 'single') { + this.dropdown?.close(); + } + this.onChange(this.value!); + } + + private updateItems() { + let values: T[] = []; + if (this.mode === 'single') { + values = [this.value as T]; + } else { + values = (this.value as T[]) || []; + } + + this.currentItems = []; + this.allItems = []; + + // mark all user-selected items as "deselected" first + this.userProvidedItems?.forEach(item => { + item.selected = false; + this.allItems.push(item); + }); + + for (let i = 0; i < values.length; i++) { + const val = values[i]; + let option: SelectOption | undefined = this.userProvidedItems?.find(item => item.value === val); + if (!option) { + if (!this._dynamicValues) { + continue + } + + option = { + selected: true, + value: val, + label: `${val}`, + } + this.allItems.push(option); + } else { + option.selected = true + } + + this.currentItems.push(option); + } + + if (this.sortValues) { + this.allItems.sort((a, b) => { + if (b.selected && !a.selected) { + return 1; + } + + if (a.selected && !b.selected) { + return -1; + } + + return this.sortBy(a, b) + }) + } + } + + writeValue(value: SelectValue): void { + this.value = value; + + this.updateItems(); + + this.cdr.markForCheck(); + } + + onChange = (value: SelectValue): void => { } + registerOnChange(fn: (value: SelectValue) => void): void { + this.onChange = fn; + } + + onTouch = (): void => { } + registerOnTouched(fn: () => void): void { + this.onTouch = fn; + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tabs/_tab-group.scss b/desktop/angular/projects/safing/ui/src/lib/tabs/_tab-group.scss new file mode 100644 index 00000000..4d63b670 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tabs/_tab-group.scss @@ -0,0 +1,3 @@ +sfng-tab-group { + @apply flex flex-col overflow-hidden; +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tabs/index.ts b/desktop/angular/projects/safing/ui/src/lib/tabs/index.ts new file mode 100644 index 00000000..4fd3296a --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tabs/index.ts @@ -0,0 +1,4 @@ +export { SfngTabComponent, SfngTabContentDirective } from './tab'; +export { SfngTabContentScrollEvent, SfngTabGroupComponent } from './tab-group'; +export { SfngTabModule as TabModule } from './tabs.module'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.html b/desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.html new file mode 100644 index 00000000..f78ff738 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.html @@ -0,0 +1,24 @@ +
+ + +
+ + {{ tab.title }} + + + + + +
+ + +
+
+ + diff --git a/desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.ts b/desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.ts new file mode 100644 index 00000000..e4a65f6e --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.ts @@ -0,0 +1,352 @@ +import { ListKeyManager } from "@angular/cdk/a11y"; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal"; +import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, ContentChildren, DestroyRef, ElementRef, EventEmitter, Injector, Input, OnInit, Output, QueryList, ViewChild, ViewChildren, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Observable, Subject } from "rxjs"; +import { distinctUntilChanged, map, startWith } from "rxjs/operators"; +import { SfngTabComponent, TAB_ANIMATION_DIRECTION, TAB_PORTAL, TAB_SCROLL_HANDLER, TabOutletComponent } from "./tab"; + +export interface SfngTabContentScrollEvent { + event?: Event; + scrollTop: number; + previousScrollTop: number; +} + +/** + * Tab group component for rendering a tab-style navigation with support for + * keyboard navigation and type-ahead. Tab content are lazy loaded using a + * structural directive. + * The tab group component also supports adding the current active tab index + * to the active route so it is possible to navigate through tabs using back/forward + * keys (browser history) as well. + * + * Example: + * + * + * + *
+ * Some content + *
+ *
+ * + * + *
+ * Some different content + *
+ *
+ * + *
+ */ +@Component({ + selector: 'sfng-tab-group', + templateUrl: './tab-group.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngTabGroupComponent implements AfterContentInit, AfterViewInit, OnInit { + @ContentChildren(SfngTabComponent) + tabs: QueryList | null = null; + + /** References to all tab header elements */ + @ViewChildren('tabHeader', { read: ElementRef }) + tabHeaders: QueryList> | null = null; + + /** Reference to the active tab bar element */ + @ViewChild('activeTabBar', { read: ElementRef, static: false }) + activeTabBar: ElementRef | null = null; + + /** Reference to the portal outlet that we will use to render a TabOutletComponent. */ + @ViewChild(CdkPortalOutlet, { static: true }) + portalOutlet: CdkPortalOutlet | null = null; + + @Output() + tabContentScroll = new EventEmitter(); + + /** The name of the tab group. Used to update the currently active tab in the route */ + @Input() + name = 'tab' + + @Input() + outletClass = ''; + + private scrollTop: number = 0; + + /** Whether or not the current tab should be syncronized with the angular router using a query parameter */ + @Input() + set linkRouter(v: any) { + this._linkRouter = coerceBooleanProperty(v) + } + get linkRouter() { return this._linkRouter } + private _linkRouter = true; + + /** Whether or not the default tab header should be rendered */ + @Input() + set customHeader(v: any) { + this._customHeader = coerceBooleanProperty(v) + } + get customHeader() { return this._customHeader } + private _customHeader = false; + + private tabActivate$ = new Subject(); + private destroyRef = inject(DestroyRef); + + /** Emits the tab QueryList every time there are changes to the content-children */ + get tabs$() { + return this.tabs?.changes + .pipe( + map(() => this.tabs), + startWith(this.tabs) + ) + } + + /** onActivate fires when a tab has been activated. */ + get onActivate(): Observable { return this.tabActivate$.asObservable() } + + /** the index of the currently active tab. */ + activeTabIndex = -1; + + /** The key manager used to support keyboard navigation and type-ahead in the tab group */ + private keymanager: ListKeyManager | null = null; + + /** Used to force the animation direction when calling activateTab. */ + private forceAnimationDirection: 'left' | 'right' | null = null; + + /** + * pendingTabIdx holds the id or the index of a tab that should be activated after the component + * has been bootstrapped. We need to cache this value here because the ActivatedRoute might emit + * before we are AfterViewInit. + */ + private pendingTabIdx: string | null = null; + + constructor( + private injector: Injector, + private route: ActivatedRoute, + private router: Router, + private cdr: ChangeDetectorRef + ) { } + + /** + * @private + * Used to forward keyboard events to the keymanager. + */ + onKeydown(v: KeyboardEvent) { + this.keymanager?.onKeydown(v); + } + + ngOnInit(): void { + this.route.queryParamMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map(params => params.get(this.name)), + distinctUntilChanged(), + ) + .subscribe(newIdx => { + if (!this._linkRouter) { + return; + } + + if (!!this.keymanager && !!this.tabs) { + const actualIndex = this.getIndex(newIdx); + if (actualIndex !== null) { + this.keymanager.setActiveItem(actualIndex); + this.cdr.markForCheck(); + } + } else { + this.pendingTabIdx = newIdx; + } + }) + } + + ngAfterContentInit(): void { + this.keymanager = new ListKeyManager(this.tabs!) + .withHomeAndEnd() + .withHorizontalOrientation("ltr") + .withTypeAhead() + .withWrap() + + this.tabs!.changes + .subscribe(() => { + if (this.portalOutlet?.hasAttached()) { + if (this.tabs!.length === 0) { + this.portalOutlet.detach(); + } + } else { + if (this.tabs!.length > 0) { + this.activateTab(0) + } + } + + }) + + this.keymanager.change + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(change => { + const activeTab = this.tabs!.get(change); + if (!!activeTab && !!activeTab.tabContent) { + const prevIdx = this.activeTabIndex; + + let animationDirection: 'left' | 'right' = prevIdx < change ? 'left' : 'right'; + if (this.forceAnimationDirection !== null) { + animationDirection = this.forceAnimationDirection; + this.forceAnimationDirection = null; + } + + if (this.portalOutlet?.attachedRef) { + // we know for sure that attachedRef is a ComponentRef of TabOutletComponent + const ref = (this.portalOutlet.attachedRef as ComponentRef) + ref.instance._animateDirection = animationDirection; + ref.instance.outletClass = this.outletClass; + ref.changeDetectorRef.detectChanges(); + } + + this.portalOutlet?.detach(); + + const newOutletPortal = this.createTabOutlet(activeTab, animationDirection); + this.activeTabIndex = change; + this.tabContentScroll.next({ + scrollTop: 0, + previousScrollTop: this.scrollTop, + }) + + this.scrollTop = 0; + + this.tabActivate$.next(activeTab.id); + this.portalOutlet?.attach(newOutletPortal); + + this.repositionTabBar(); + + if (this._linkRouter) { + this.router.navigate([], { + queryParams: { + ...this.route.snapshot.queryParams, + [this.name]: this.activeTabIndex, + } + }) + } + this.cdr.markForCheck(); + } + }); + + if (this.pendingTabIdx === null) { + // active the first tab that is NOT disabled + const firstActivatable = this.tabs?.toArray().findIndex(tap => !tap.disabled); + if (firstActivatable !== undefined) { + this.keymanager.setActiveItem(firstActivatable); + } + } else { + const idx = this.getIndex(this.pendingTabIdx); + if (idx !== null) { + this.keymanager.setActiveItem(idx); + this.pendingTabIdx = null; + } + } + } + + ngAfterViewInit(): void { + this.repositionTabBar(); + this.tabHeaders?.changes.subscribe(() => this.repositionTabBar()) + setTimeout(() => this.repositionTabBar(), 250) + } + + /** + * @private + * Activates a new tab + * + * @param idx The index of the new tab. + */ + activateTab(idx: number, forceDirection?: 'left' | 'right') { + if (forceDirection !== undefined) { + this.forceAnimationDirection = forceDirection; + } + + this.keymanager?.setActiveItem(idx); + } + + private getIndex(newIdx: string | null): number | null { + let actualIndex: number = -1; + if (!this.tabs) { + return null; + } + + if (newIdx === undefined || newIdx === null) { // not present in the URL + return null; + } + if (isNaN(+newIdx)) { // likley the ID of a tab + actualIndex = this.tabs?.toArray().findIndex(tab => tab.id === newIdx) || -1; + } else { // it's a number as a string + actualIndex = +newIdx; + } + + if (actualIndex < 0) { + return null; + } + return actualIndex; + } + + private repositionTabBar() { + if (!this.tabHeaders) { + return; + } + + requestAnimationFrame(() => { + const tabHeader = this.tabHeaders!.get(this.activeTabIndex); + if (!tabHeader || !this.activeTabBar) { + return; + } + const rect = tabHeader.nativeElement.getBoundingClientRect(); + const transform = `translate(${tabHeader.nativeElement.offsetLeft}px, ${tabHeader.nativeElement.offsetTop + rect.height}px)` + this.activeTabBar.nativeElement.style.width = `${rect.width}px` + this.activeTabBar.nativeElement.style.transform = transform; + this.activeTabBar.nativeElement.style.opacity = '1'; + + // initialize animations on the active-tab-bar required + if (!this.activeTabBar.nativeElement.classList.contains("transition-all")) { + // only initialize the transitions if this is the very first "reposition" + // this is to prevent the bar from animating to the "bottom" line of the tab + // header the first time. + requestAnimationFrame(() => { + this.activeTabBar?.nativeElement.classList.add("transition-all", "duration-200"); + }) + } + }) + } + + private createTabOutlet(tab: SfngTabComponent, animationDir: 'left' | 'right'): ComponentPortal { + const injector = Injector.create({ + providers: [ + { + provide: TAB_PORTAL, + useValue: tab.tabContent!.portal, + }, + { + provide: TAB_ANIMATION_DIRECTION, + useValue: animationDir, + }, + { + provide: TAB_SCROLL_HANDLER, + useValue: (e: Event) => { + const newScrollTop = (e.target as HTMLElement).scrollTop; + + tab.tabContentScroll.next(e); + this.tabContentScroll.next({ + event: e, + scrollTop: newScrollTop, + previousScrollTop: this.scrollTop, + }); + + this.scrollTop = newScrollTop; + } + }, + ], + parent: this.injector, + name: 'TabOutletInjectot', + }) + + return new ComponentPortal( + TabOutletComponent, + undefined, + injector + ) + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tabs/tab.ts b/desktop/angular/projects/safing/ui/src/lib/tabs/tab.ts new file mode 100644 index 00000000..31f71226 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tabs/tab.ts @@ -0,0 +1,167 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { ListKeyManagerOption } from "@angular/cdk/a11y"; +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { CdkPortalOutlet, TemplatePortal } from "@angular/cdk/portal"; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Directive, EventEmitter, Inject, InjectionToken, Input, Output, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core"; + +/** TAB_PORTAL is the injection token used to inject the TabContentDirective portal into TabOutletComponent */ +export const TAB_PORTAL = new InjectionToken('TAB_PORTAL'); + +/** TAB_ANIMATION_DIRECTION is the injection token used to control the :enter animation origin of TabOutletComponent */ +export const TAB_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('TAB_ANIMATION_DIRECTION'); + +/** TAB_SCROLL_HANDLER is called by the SfngTabOutletComponent when a scroll event occurs. */ +export const TAB_SCROLL_HANDLER = new InjectionToken<(_: Event) => void>('TAB_SCROLL_HANDLER') + +/** + * Structural directive (*sfngTabContent) to defined lazy-loaded tab content. + */ +@Directive({ + selector: '[sfngTabContent]', +}) +export class SfngTabContentDirective { + portal: TemplatePortal; + + constructor( + public readonly templateRef: TemplateRef, + public readonly viewRef: ViewContainerRef, + ) { + this.portal = new TemplatePortal(this.templateRef, this.viewRef); + } +} + + +/** + * The tab component that is used to define a new tab as a part of a tab group. + * The content of the tab is lazy-loaded by using the TabContentDirective. + */ +@Component({ + selector: 'sfng-tab', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngTabComponent implements ListKeyManagerOption { + @ContentChild(SfngTabContentDirective, { static: false }) + tabContent: SfngTabContentDirective | null = null; + + /** The ID of the tab used to programatically activate the tab. */ + @Input() + id = ''; + + /** The title for the tab as displayed in the tab group header. */ + @Input() + title = ''; + + /** The key for the tip up in the tab group header. */ + @Input() + tipUpKey = ''; + + @Input() + set warning(v) { + this._warning = coerceBooleanProperty(v) + } + get warning() { return this._warning } + private _warning = false; + + /** Emits when the tab content is scrolled */ + @Output() + tabContentScroll = new EventEmitter(); + + /** Whether or not the tab is currently disabled. */ + @Input() + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v); + } + get disabled() { + return this._disabled; + } + private _disabled: boolean = false; + + /** getLabel is used by the list key manager to support type-ahead */ + getLabel() { return this.title } +} + + +/** + * A simple wrapper component around CdkPortalOutlet to add nice + * move animations. + */ +@Component({ + selector: 'sfng-tab-outlet', + template: ` +
+ +
+ `, + styles: [ + ` + :host{ + display: flex; + flex-direction: column; + overflow: hidden; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger( + 'moveInOut', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateX({{ in }})' }), + animate('.2s ease-in', + style({ opacity: 1, transform: 'translateX(0%)' })) + ], + { params: { in: '100%' } } // default parameters + ), + transition( + ':leave', + [ + style({ opacity: 1 }), + animate('.2s ease-out', + style({ opacity: 0, transform: 'translateX({{ out }})' })) + ], + { params: { out: '-100%' } } // default parameters + ) + ] + )] +}) +export class TabOutletComponent implements AfterViewInit { + _appAnimate = false; + + @Input() + outletClass = '' + + get in() { + return this._animateDirection == 'left' ? '100%' : '-100%' + } + get out() { + return this._animateDirection == 'left' ? '-100%' : '100%' + } + + onTabContentScroll(event: Event) { + if (!!this.scrollHandler) { + this.scrollHandler(event) + } + } + + @ViewChild(CdkPortalOutlet, { static: true }) + portalOutlet!: CdkPortalOutlet; + + constructor( + @Inject(TAB_PORTAL) public portal: TemplatePortal, + @Inject(TAB_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right', + @Inject(TAB_SCROLL_HANDLER) public scrollHandler: (_: Event) => void, + private cdr: ChangeDetectorRef + ) { } + + ngAfterViewInit(): void { + this.portalOutlet?.attached + .subscribe(() => { + this._appAnimate = true; + this.cdr.detectChanges(); + }) + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tabs/tabs.module.ts b/desktop/angular/projects/safing/ui/src/lib/tabs/tabs.module.ts new file mode 100644 index 00000000..e1540cb4 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tabs/tabs.module.ts @@ -0,0 +1,28 @@ +import { PortalModule } from "@angular/cdk/portal"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { SfngTipUpModule } from "../tipup"; +import { SfngTabComponent, SfngTabContentDirective, TabOutletComponent } from "./tab"; +import { SfngTabGroupComponent } from "./tab-group"; + +@NgModule({ + imports: [ + CommonModule, + PortalModule, + SfngTipUpModule, + BrowserAnimationsModule + ], + declarations: [ + SfngTabContentDirective, + SfngTabComponent, + SfngTabGroupComponent, + TabOutletComponent, + ], + exports: [ + SfngTabContentDirective, + SfngTabComponent, + SfngTabGroupComponent + ] +}) +export class SfngTabModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/_tipup.scss b/desktop/angular/projects/safing/ui/src/lib/tipup/_tipup.scss new file mode 100644 index 00000000..b6b93040 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/_tipup.scss @@ -0,0 +1,52 @@ +sfng-tipup-container { + display: block; + + caption { + @apply text-sm; + opacity: .6; + font-size: .6rem; + } + + h1 { + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 1rem; + } + + .message, + h1 { + flex-shrink: 0; + text-overflow: ellipsis; + word-break: normal; + } + + .message { + font-size: 0.75rem; + flex-grow: 1; + opacity: .8; + max-width: 300px; + padding: 0; + } + + .close-icon { + position: absolute; + top: 1rem; + right: 1rem; + opacity: .7; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + + .buttons { + width: 100%; + display: flex; + justify-content: space-between; + } + + a { + text-decoration: underline; + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/anchor.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/anchor.ts new file mode 100644 index 00000000..986d0378 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/anchor.ts @@ -0,0 +1,43 @@ +import { Directive, ElementRef, HostBinding, Input, isDevMode } from "@angular/core"; +import { SfngTipUpPlacement } from "./utils"; + +@Directive({ + selector: '[sfngTipUpAnchor]', +}) +export class SfngTipUpAnchorDirective implements SfngTipUpPlacement { + constructor( + public readonly elementRef: ElementRef, + ) { } + + origin: 'left' | 'right' = 'right'; + offset: number = 10; + + @HostBinding('class.active-tipup-anchor') + isActiveAnchor = false; + + @Input() + set sfngTipUpAnchor(posSpec: string | undefined) { + const parts = (posSpec || '').split(';') + if (parts.length > 2) { + if (isDevMode()) { + throw new Error(`Invalid value "${posSpec}" for [sfngTipUpAnchor]`); + } + return; + } + + if (parts[0] === 'left') { + this.origin = 'left'; + } else { + this.origin = 'right'; + } + + if (parts.length === 2) { + this.offset = +parts[1]; + if (isNaN(this.offset)) { + this.offset = 10; + } + } else { + this.offset = 10; + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/clone-node.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/clone-node.ts new file mode 100644 index 00000000..e0550060 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/clone-node.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Creates a deep clone of an element. */ +export function deepCloneNode(node: HTMLElement): HTMLElement { + const clone = node.cloneNode(true) as HTMLElement; + const descendantsWithId = clone.querySelectorAll('[id]'); + const nodeName = node.nodeName.toLowerCase(); + + // Remove the `id` to avoid having multiple elements with the same id on the page. + clone.removeAttribute('id'); + + for (let i = 0; i < descendantsWithId.length; i++) { + descendantsWithId[i].removeAttribute('id'); + } + + if (nodeName === 'canvas') { + transferCanvasData(node as HTMLCanvasElement, clone as HTMLCanvasElement); + } else if (nodeName === 'input' || nodeName === 'select' || nodeName === 'textarea') { + transferInputData(node as HTMLInputElement, clone as HTMLInputElement); + } + + transferData('canvas', node, clone, transferCanvasData); + transferData('input, textarea, select', node, clone, transferInputData); + return clone; +} + +/** Matches elements between an element and its clone and allows for their data to be cloned. */ +function transferData(selector: string, node: HTMLElement, clone: HTMLElement, + callback: (source: T, clone: T) => void) { + const descendantElements = node.querySelectorAll(selector); + + if (descendantElements.length) { + const cloneElements = clone.querySelectorAll(selector); + + for (let i = 0; i < descendantElements.length; i++) { + callback(descendantElements[i], cloneElements[i]); + } + } +} + +// Counter for unique cloned radio button names. +let cloneUniqueId = 0; + +/** Transfers the data of one input element to another. */ +function transferInputData(source: Element & { value: string }, + clone: Element & { value: string; name: string; type: string }) { + // Browsers throw an error when assigning the value of a file input programmatically. + if (clone.type !== 'file') { + clone.value = source.value; + } + + // Radio button `name` attributes must be unique for radio button groups + // otherwise original radio buttons can lose their checked state + // once the clone is inserted in the DOM. + if (clone.type === 'radio' && clone.name) { + clone.name = `sfng-clone-${clone.name}-${cloneUniqueId++}`; + } +} + +/** Transfers the data of one canvas element to another. */ +function transferCanvasData(source: HTMLCanvasElement, clone: HTMLCanvasElement) { + const context = clone.getContext('2d'); + + if (context) { + // In some cases `drawImage` can throw (e.g. if the canvas size is 0x0). + // We can't do much about it so just ignore the error. + try { + context.drawImage(source, 0, 0); + } catch { } + } +} + +/** + * Gets a 3d `transform` that can be applied to an element. + * @param x Desired position of the element along the X axis. + * @param y Desired position of the element along the Y axis. + */ +export function getTransform(x: number, y: number): string { + // Round the transforms since some browsers will + // blur the elements for sub-pixel transforms. + return `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`; +} + +/** + * Matches the target element's size to the source's size. + * @param target Element that needs to be resized. + * @param sourceRect Dimensions of the source element. + */ +export function matchElementSize(target: HTMLElement, sourceRect: ClientRect): void { + target.style.width = `${sourceRect.width}px`; + target.style.height = `${sourceRect.height}px`; + target.style.transform = getTransform(sourceRect.left, sourceRect.top); +} + +/** + * Shallow-extends a stylesheet object with another stylesheet-like object. + * Note that the keys in `source` have to be dash-cased. + */ +export function extendStyles(dest: CSSStyleDeclaration, + source: Record, + importantProperties?: Set) { + for (let key in source) { + if (source.hasOwnProperty(key)) { + const value = source[key]; + + if (value) { + dest.setProperty(key, value, importantProperties?.has(key) ? 'important' : ''); + } else { + dest.removeProperty(key); + } + } + } + + return dest; +} + +export function removeNode(node: Node | null) { + if (node && node.parentNode) { + node.parentNode.removeChild(node); + } +} + diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/css-utils.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/css-utils.ts new file mode 100644 index 00000000..8f58dff2 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/css-utils.ts @@ -0,0 +1,87 @@ + +export function synchronizeCssStyles(src: HTMLElement, destination: HTMLElement, skipStyles: Set) { + // Get a list of all the source and destination elements + const srcElements = >src.getElementsByTagName('*'); + const dstElements = >destination.getElementsByTagName('*'); + + cloneStyle(src, destination, skipStyles); + + // For each element + for (let i = srcElements.length; i--;) { + const srcElement = srcElements[i]; + const dstElement = dstElements[i]; + cloneStyle(srcElement, dstElement, skipStyles); + } +} + +function cloneStyle(srcElement: HTMLElement, dstElement: HTMLElement, skipStyles: Set) { + const sourceElementStyles = document.defaultView!.getComputedStyle(srcElement, ''); + const styleAttributeKeyNumbers = Object.keys(sourceElementStyles); + + // Copy the attribute + for (let j = 0; j < styleAttributeKeyNumbers.length; j++) { + const attributeKeyNumber = styleAttributeKeyNumbers[j]; + const attributeKey: string = sourceElementStyles[attributeKeyNumber as any]; + if (!isNaN(+attributeKey)) { + continue + } + if (attributeKey === 'cssText') { + continue + } + + if (skipStyles.has(attributeKey)) { + continue + } + + try { + dstElement.style[attributeKey as any] = sourceElementStyles[attributeKey as any]; + } catch (e) { + console.error(attributeKey, e); + } + } +} + +/** + * Returns a CSS selector for el from rootNode. + * + * @param el The source element to get the CSS path to + * @param rootNode The root node at which the CSS path should be applyable + * @returns A CSS selector to access el from rootNode. + */ +export function getCssSelector(el: HTMLElement, rootNode: HTMLElement | null): string { + if (!el) { + return ''; + } + let stack = []; + let isShadow = false; + while (el !== rootNode && el.parentNode !== null) { + // console.log(el.nodeName); + let sibCount = 0; + let sibIndex = 0; + // get sibling indexes + for (let i = 0; i < (el.parentNode as HTMLElement).childNodes.length; i++) { + let sib = (el.parentNode as HTMLElement).childNodes[i]; + if (sib.nodeName == el.nodeName) { + if (sib === el) { + sibIndex = sibCount; + } + sibCount++; + } + } + let nodeName = el.nodeName.toLowerCase(); + if (isShadow) { + throw new Error(`cannot traverse into shadow dom.`) + } + if (sibCount > 1) { + stack.unshift(nodeName + ':nth-of-type(' + (sibIndex + 1) + ')'); + } else { + stack.unshift(nodeName); + } + el = el.parentNode as HTMLElement; + if (el.nodeType === 11) { // for shadow dom, we + isShadow = true; + el = (el as any).host; + } + } + return stack.join(' > '); +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/index.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/index.ts new file mode 100644 index 00000000..bd600272 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/index.ts @@ -0,0 +1,6 @@ +export * from './anchor'; +export * from './tipup'; +export * from './tipup-component'; +export * from './tipup.module'; +export * from './translations'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/safe.pipe.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/safe.pipe.ts new file mode 100644 index 00000000..0cbf2855 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/safe.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser'; + +@Pipe({ + name: 'safe' +}) +export class SafePipe implements PipeTransform { + + constructor(protected sanitizer: DomSanitizer) { } + + public transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl { + switch (type) { + case 'html': return this.sanitizer.bypassSecurityTrustHtml(value); + case 'style': return this.sanitizer.bypassSecurityTrustStyle(value); + case 'script': return this.sanitizer.bypassSecurityTrustScript(value); + case 'url': return this.sanitizer.bypassSecurityTrustUrl(value); + case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value); + default: throw new Error(`Invalid safe type specified: ${type}`); + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/tipup-component.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup-component.ts new file mode 100644 index 00000000..8747b098 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup-component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core"; +import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog"; +import { SfngTipUpService } from "./tipup"; +import { ActionRunner, Button, SFNG_TIP_UP_ACTION_RUNNER, TipUp } from './translations'; +import { TIPUP_TOKEN } from "./utils"; + +@Component({ + selector: 'sfng-tipup-container', + templateUrl: './tipup.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngTipUpComponent implements OnInit, TipUp { + title: string = 'N/A'; + content: string = 'N/A'; + nextKey?: string; + buttons?: Button[]; + url?: string; + urlText: string = 'Read More'; + + constructor( + @Inject(TIPUP_TOKEN) public readonly token: string, + @Inject(SFNG_DIALOG_REF) private readonly dialogRef: SfngDialogRef, + @Inject(SFNG_TIP_UP_ACTION_RUNNER) private runner: ActionRunner, + private tipupService: SfngTipUpService, + ) { } + + ngOnInit() { + const doc = this.tipupService.getTipUp(this.token); + if (!!doc) { + Object.assign(this, doc); + this.urlText = doc.urlText || 'Read More'; + } + } + + async next() { + if (!this.nextKey) { + return; + } + + this.tipupService.open(this.nextKey); + this.dialogRef.close(); + } + + async runAction(btn: Button) { + await this.runner.performAction(btn.action); + + // if we have a nextKey for the button but do not do in-app + // routing we should be able to open the next tipup as soon + // as the action finished + if (!!btn.nextKey) { + this.tipupService.waitFor(btn.nextKey!) + .subscribe({ + next: () => { + this.dialogRef.close(); + this.tipupService.open(btn.nextKey!); + }, + error: console.error + }) + } else { + this.close(); + } + } + + close() { + this.dialogRef.close(); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.html b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.html new file mode 100644 index 00000000..ac54fe8a --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.html @@ -0,0 +1,22 @@ +
+ Tip + + + + +

+ + + + {{ urlText }} + +
+
+ +
+ +
+
diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.module.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.module.ts new file mode 100644 index 00000000..42378c6f --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.module.ts @@ -0,0 +1,47 @@ +import { CommonModule } from "@angular/common"; +import { ModuleWithProviders, NgModule, Type } from "@angular/core"; +import { MarkdownModule } from "ngx-markdown"; +import { SfngDialogModule } from "../dialog"; +import { SfngTipUpAnchorDirective } from './anchor'; +import { SfngsfngTipUpTriggerDirective, SfngTipUpIconComponent } from './tipup'; +import { SfngTipUpComponent } from './tipup-component'; +import { ActionRunner, HelpTexts, SFNG_TIP_UP_ACTION_RUNNER, SFNG_TIP_UP_CONTENTS } from "./translations"; +import { SafePipe } from "./safe.pipe"; + +@NgModule({ + imports: [ + CommonModule, + MarkdownModule.forChild(), + SfngDialogModule, + ], + declarations: [ + SfngTipUpIconComponent, + SfngsfngTipUpTriggerDirective, + SfngTipUpComponent, + SfngTipUpAnchorDirective, + SafePipe + ], + exports: [ + SfngTipUpIconComponent, + SfngsfngTipUpTriggerDirective, + SfngTipUpComponent, + SfngTipUpAnchorDirective + ], +}) +export class SfngTipUpModule { + static forRoot(text: HelpTexts, runner: Type>): ModuleWithProviders { + return { + ngModule: SfngTipUpModule, + providers: [ + { + provide: SFNG_TIP_UP_CONTENTS, + useValue: text, + }, + { + provide: SFNG_TIP_UP_ACTION_RUNNER, + useExisting: runner, + } + ] + } + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.ts new file mode 100644 index 00000000..7f6fbd85 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/tipup.ts @@ -0,0 +1,526 @@ +/* eslint-disable @angular-eslint/no-input-rename */ +import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { _getShadowRoot } from '@angular/cdk/platform'; +import { DOCUMENT } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, ElementRef, HostBinding, HostListener, Inject, Injectable, Injector, Input, NgZone, OnDestroy, Optional, Renderer2, RendererFactory2 } from '@angular/core'; +import { Observable, of, Subject } from 'rxjs'; +import { debounce, debounceTime, filter, map, skip, take, timeout } from 'rxjs/operators'; +import { SfngDialogRef, SfngDialogService } from '../dialog'; +import { SfngTipUpAnchorDirective } from './anchor'; +import { deepCloneNode, extendStyles, matchElementSize, removeNode } from './clone-node'; +import { getCssSelector, synchronizeCssStyles } from './css-utils'; +import { SfngTipUpComponent } from './tipup-component'; +import { Button, HelpTexts, SFNG_TIP_UP_CONTENTS, TipUp } from './translations'; +import { SfngTipUpPlacement, TIPUP_TOKEN } from './utils'; + +@Directive({ + selector: '[sfngTipUpTrigger]', +}) +export class SfngsfngTipUpTriggerDirective implements OnDestroy { + constructor( + public readonly elementRef: ElementRef, + public dialog: SfngDialogService, + @Optional() @Inject(SfngTipUpAnchorDirective) public anchor: SfngTipUpAnchorDirective | ElementRef | HTMLElement, + @Inject(SFNG_TIP_UP_CONTENTS) private tipUpContents: HelpTexts, + private tipupService: SfngTipUpService, + private cdr: ChangeDetectorRef, + ) { } + + private dialogRef: SfngDialogRef | null = null; + + /** + * The helptext token used to search for the tip up defintion. + */ + @Input('sfngTipUpTrigger') + set textKey(s: string) { + if (!!this._textKey) { + this.tipupService.deregister(this._textKey, this); + } + this._textKey = s; + this.tipupService.register(this._textKey, this); + } + get textKey() { return this._textKey; } + private _textKey: string = ''; + + /** + * The text to display inside the tip up. If unset, the tipup definition + * will be loaded form helptexts.yaml. + * This input property is mainly designed for programatic/dynamic tip-up generation + */ + @Input('sfngTipUpText') + text: string | undefined; + + @Input('sfngTipUpTitle') + title: string | undefined; + + @Input('sfngTipUpButtons') + buttons: Button[] | undefined; + + /** + * asTipUp returns a tip-up definition built from the input + * properties sfngTipUpText and sfngTipUpTitle. If none are set + * then null is returned. + */ + asTipUp(): TipUp | null { + // TODO(ppacher): we could also merge the defintions from MyYamlFile + // and the properties set on this directive.... + if (!this.text) { + return this.tipUpContents[this.textKey]; + } + return { + title: this.title || '', + content: this.text, + buttons: this.buttons, + } + } + + /** + * The default anchor for the tipup if non is provided via Dependency-Injection + * or using sfngTipUpAnchorRef + */ + @Input('sfngTipUpDefaultAnchor') + defaultAnchor: ElementRef | HTMLElement | null = null; + + /** Optionally overwrite the anchor element received via Dependency Injection */ + @Input('sfngTipUpAnchorRef') + set anchorRef(ref: ElementRef | HTMLElement | null) { + this.anchor = ref ?? this.anchor; + } + + /** Used to ensure all tip-up triggers have a pointer cursor */ + @HostBinding('style.cursor') + cursor = 'pointer'; + + /** De-register ourself upon destroy */ + ngOnDestroy() { + this.tipupService.deregister(this.textKey, this); + } + + /** Whether or not we're passive-only and thus do not handle click-events form the user */ + @Input('sfngTipUpPassive') + set passive(v: any) { + this._passive = coerceBooleanProperty(v ?? true); + } + get passive() { return this._passive; } + private _passive = false; + + @Input('sfngTipUpOffset') + set offset(v: any) { + this._defaultOffset = coerceNumberProperty(v) + } + get offset() { return this._defaultOffset } + private _defaultOffset = 20; + + @Input('sfngTipUpPlacement') + placement: SfngTipUpPlacement | null = null; + + @HostListener('click', ['$event']) + onClick(event?: MouseEvent): Promise { + if (!!event) { + // if there's a click event the user actually clicked the element. + // we only handle this if we're not marked as passive. + if (this._passive) { + return Promise.resolve(); + } + + event.preventDefault(); + event.stopPropagation(); + } + + if (!!this.dialogRef) { + this.dialogRef.close(); + return Promise.resolve(); + } + + let anchorElement: ElementRef | HTMLElement | null = this.defaultAnchor || this.elementRef; + let placement: SfngTipUpPlacement | null = this.placement; + + if (!!this.anchor) { + if (this.anchor instanceof SfngTipUpAnchorDirective) { + anchorElement = this.anchor.elementRef; + placement = this.anchor; + } else { + anchorElement = this.anchor; + } + } + + this.dialogRef = this.tipupService.createTipup( + anchorElement, + this.textKey, + this, + placement, + ) + + this.dialogRef.onClose + .pipe(take(1)) + .subscribe(() => { + this.dialogRef = null; + this.cdr.markForCheck(); + }); + + this.cdr.detectChanges(); + + return this.dialogRef.onStateChange + .pipe( + filter(state => state === 'opening'), + take(1), + ) + .toPromise() + } +} + +@Component({ + selector: 'sfng-tipup', + template: + ` + + + + + `, + styles: [ + ` + :host { + display: inline-block; + width : 1rem; + position: relative; + opacity: 0.55; + cursor : pointer; + align-self: center; + } + + :host:hover { + opacity: 1; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngTipUpIconComponent implements SfngTipUpPlacement { + @Input() + key: string = ''; + + // see sfngTipUpTrigger sfngTipUpText and sfngTipUpTitle + @Input() text: string | undefined = undefined; + @Input() title: string | undefined = undefined; + @Input() buttons: Button[] | undefined = undefined; + + @Input() + anchor: ElementRef | HTMLElement | null = null; + + @Input('placement') + origin: 'left' | 'right' = 'right'; + + @Input() + set offset(v: any) { + this._offset = coerceNumberProperty(v); + } + get offset() { return this._offset; } + private _offset: number = 10; + + constructor(private elementRef: ElementRef) { } + + get placement(): SfngTipUpPlacement { + return this + } + + get parent(): HTMLElement | null { + return (this.elementRef?.nativeElement as HTMLElement)?.parentElement; + } +} + + +@Injectable({ + providedIn: 'root' +}) +export class SfngTipUpService { + tipups = new Map(); + + private _onRegister = new Subject(); + private _onUnregister = new Subject(); + + get onRegister(): Observable { + return this._onRegister.asObservable(); + } + + get onUnregister(): Observable { + return this._onUnregister.asObservable(); + } + + waitFor(key: string): Observable { + if (this.tipups.has(key)) { + return of(undefined); + } + + return this.onRegister + .pipe( + filter(val => val === key), + debounce(() => this.ngZone.onStable.pipe(skip(2))), + debounceTime(1000), + take(1), + map(() => { }), + timeout(5000), + ); + } + + private renderer: Renderer2; + + constructor( + @Inject(DOCUMENT) private _document: Document, + private dialog: SfngDialogService, + private ngZone: NgZone, + private injector: Injector, + rendererFactory: RendererFactory2 + ) { + this.renderer = rendererFactory.createRenderer(null, null) + } + + register(key: string, trigger: SfngsfngTipUpTriggerDirective) { + if (this.tipups.has(key)) { + return; + } + + this.tipups.set(key, trigger); + this._onRegister.next(key); + } + + deregister(key: string, trigger: SfngsfngTipUpTriggerDirective) { + if (this.tipups.get(key) === trigger) { + this.tipups.delete(key); + this._onUnregister.next(key); + } + } + + getTipUp(key: string): TipUp | null { + return this.tipups.get(key)?.asTipUp() || null; + } + + private _latestTipUp: SfngDialogRef | null = null; + + createTipup( + anchor: HTMLElement | ElementRef, + key: string, + origin?: SfngsfngTipUpTriggerDirective, + opts: SfngTipUpPlacement | null = {}, + injector?: Injector): SfngDialogRef { + + const lastTipUp = this._latestTipUp + let closePrevious = () => { + if (!!lastTipUp) { + lastTipUp.close(); + } + } + + // make sure we have an ElementRef to work with + if (!(anchor instanceof ElementRef)) { + anchor = new ElementRef(anchor) + } + + // the the origin placement of the tipup + const positions: ConnectedPosition[] = []; + if (opts?.origin === 'left') { + positions.push({ + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + }) + } else { + positions.push({ + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + }) + } + + // determine the offset to the tipup origin + let offset = opts?.offset ?? 10; + if (opts?.origin === 'left') { + offset *= -1; + } + + let postitionStrategy = this.dialog.position() + .flexibleConnectedTo(anchor) + .withPositions(positions) + .withDefaultOffsetX(offset); + + const inj = Injector.create({ + providers: [ + { + useValue: key, + provide: TIPUP_TOKEN, + } + ], + parent: injector || this.injector, + }); + + + const newTipUp = this.dialog.create(SfngTipUpComponent, { + dragable: false, + autoclose: true, + backdrop: 'light', + injector: inj, + positionStrategy: postitionStrategy + }); + this._latestTipUp = newTipUp; + + const _preview = this._createPreview(anchor.nativeElement, _getShadowRoot(anchor.nativeElement)); + + // construct a CSS selector that targets the clicked origin (sfngTipUpTriggerDirective) from within + // the anchor. We use that path to highlight the copy of the trigger-directive in the preview. + if (!!origin) { + const originSelector = getCssSelector(origin.elementRef.nativeElement, anchor.nativeElement); + let target: HTMLElement | null = null; + if (!!originSelector) { + target = _preview.querySelector(originSelector); + } else { + target = _preview; + } + + this.renderer.addClass(target, 'active-tipup-trigger') + } + + newTipUp.onStateChange + .pipe( + filter(state => state === 'open'), + take(1) + ) + .subscribe(() => { + closePrevious(); + _preview.attach() + }) + + newTipUp.onStateChange + .pipe( + filter(state => state === 'closing'), + take(1) + ) + .subscribe(() => { + if (this._latestTipUp === newTipUp) { + this._latestTipUp = null; + } + _preview.classList.remove('visible'); + setTimeout(() => { + removeNode(_preview); + }, 300) + }); + + return newTipUp; + } + + private _createPreview(element: HTMLElement, shadowRoot: ShadowRoot | null): HTMLElement & { attach: () => void } { + const preview = deepCloneNode(element); + // clone all CSS styles by applying them directly to the copied + // nodes. Though, we skip the opacity property because we use that + // a lot and it makes the preview strange .... + synchronizeCssStyles(element, preview, new Set([ + 'opacity' + ])); + + // make sure the preview element is at the exact same position + // as the original one. + matchElementSize(preview, element.getBoundingClientRect()); + + extendStyles(preview.style, { + // We have to reset the margin, because it can throw off positioning relative to the viewport. + 'margin': '0', + 'position': 'fixed', + 'top': '0', + 'left': '0', + 'z-index': '1000', + 'opacity': 'unset', + }, new Set(['position'])); + + // We add a dedicated class to the preview element so + // it can handle special higlighting itself. + preview.classList.add('tipup-preview') + + // since the user might want to click on the preview element we must + // intercept the click-event, determine the path to the target element inside + // the preview and eventually dispatch a click-event on the actual + // - real - target inside the cloned element. + preview.onclick = function (event: MouseEvent) { + let path = getCssSelector(event.target as HTMLElement, preview); + if (!!path) { + // find the target by it's CSS path + let actualTarget: HTMLElement | null = element.querySelector(path); + + // some (SVG) elements don't have a direct click() listener so we need to search + // the parents upwards to find one that implements click(). + // we're basically searching up until we reach the tag. + // + // TODO(ppacher): stop searching at the respective root node. + if (!!actualTarget) { + let iter: HTMLElement = actualTarget; + while (iter != null) { + if ('click' in iter && typeof iter['click'] === 'function') { + iter.click(); + break; + } + iter = iter.parentNode as HTMLElement; + } + } + } else { + // the user clicked the preview element directly + try { + element.click() + } catch (e) { + console.error(e); + } + } + } + + let attach = () => { + const parent = this._getPreviewInserationPoint(shadowRoot) + const cdkOverlayContainer = parent.getElementsByClassName('cdk-overlay-container')[0] + // if we find a cdkOverlayContainer in our inseration point (which we expect to be there) + // we insert the preview element right after the overlay-backdrop. This way the tip-up + // dialog will still be on top of the preview. + if (!!cdkOverlayContainer) { + const reference = cdkOverlayContainer.getElementsByClassName("cdk-overlay-backdrop")[0].nextSibling; + cdkOverlayContainer.insertBefore(preview, reference) + } else { + parent.appendChild(preview); + } + + setTimeout(() => { + preview.classList.add('visible'); + }) + } + + Object.defineProperty(preview, 'attach', { + value: attach, + }) + + return preview as any; + } + + private _getPreviewInserationPoint(shadowRoot: ShadowRoot | null): HTMLElement { + const documentRef = this._document; + return shadowRoot || + documentRef.fullscreenElement || + (documentRef as any).webkitFullscreenElement || + (documentRef as any).mozFullScreenElement || + (documentRef as any).msFullscreenElement || + documentRef.body; + } + + async open(key: string) { + const comp = this.tipups.get(key); + if (!comp) { + console.error('Tried to open unknown tip-up with key ' + key); + return; + } + comp.onClick() + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/translations.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/translations.ts new file mode 100644 index 00000000..fdc0ecd5 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/translations.ts @@ -0,0 +1,27 @@ +import { InjectionToken } from '@angular/core'; + +export const SFNG_TIP_UP_CONTENTS = new InjectionToken>('SfngTipUpContents'); +export const SFNG_TIP_UP_ACTION_RUNNER = new InjectionToken>('SfngTipUpActionRunner') + +export interface Button { + name: string; + action: T; + nextKey?: string; +} + +export interface TipUp { + title: string; + content: string; + url?: string; + urlText?: string; + buttons?: Button[]; + nextKey?: string; +} + +export interface HelpTexts { + [key: string]: TipUp; +} + +export interface ActionRunner { + performAction(action: T): Promise; +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tipup/utils.ts b/desktop/angular/projects/safing/ui/src/lib/tipup/utils.ts new file mode 100644 index 00000000..7ccffbd4 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tipup/utils.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from "@angular/core"; + +export const TIPUP_TOKEN = new InjectionToken('TipUPJSONToken'); + +export interface SfngTipUpPlacement { + origin?: 'left' | 'right'; + offset?: number; +} diff --git a/desktop/angular/projects/safing/ui/src/lib/toggle-switch/_toggle-switch.scss b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/_toggle-switch.scss new file mode 100644 index 00000000..246a7953 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/_toggle-switch.scss @@ -0,0 +1,35 @@ +sfng-toggle { + @apply flex items-center; + + label { + @apply inline-block w-10 h-5 relative bg-gray-500 rounded-full; + } + + .slider { + @apply absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-600 transition-all duration-100 rounded-full shadow-inner-xs; + } + + .dot { + @apply absolute transition-all duration-200 rounded-full bg-white; + height: 18px; + width: 18px; + bottom: 1px; + left: 1px; + } + + input:checked:not(:disabled)+.slider { + @apply bg-green-300 bg-opacity-50 text-green; + } + + input:disabled+.slider { + @apply opacity-75 cursor-not-allowed; + } + + .dot.checked { + transform: translateX(calc(2.5rem - 18px - 2px)); + } + + .dot.disabled { + transform: translateX(calc((2.5rem - 18px - 2px)/2)); + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/toggle-switch/index.ts b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/index.ts new file mode 100644 index 00000000..fbc94093 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/index.ts @@ -0,0 +1,3 @@ +export * from './toggle-switch'; +export * from './toggle.module'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.html b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.html new file mode 100644 index 00000000..69320c3a --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.html @@ -0,0 +1,20 @@ + diff --git a/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.ts b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.ts new file mode 100644 index 00000000..6b90f961 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.ts @@ -0,0 +1,59 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostListener } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'sfng-toggle', + templateUrl: './toggle-switch.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SfngToggleSwitchComponent), + multi: true, + } + ] +}) +export class SfngToggleSwitchComponent implements ControlValueAccessor { + @HostListener('blur') + onBlur() { + this.onTouch(); + } + + set disabled(v: any) { + this.setDisabledState(coerceBooleanProperty(v)) + } + get disabled() { + return this._disabled; + } + private _disabled = false; + + value: boolean = false; + + constructor(private _changeDetector: ChangeDetectorRef) { } + + setDisabledState(isDisabled: boolean) { + this._disabled = isDisabled; + this._changeDetector.markForCheck(); + } + + onValueChange(value: boolean) { + this.value = value; + this.onChange(this.value); + } + + writeValue(value: boolean) { + this.value = value; + this._changeDetector.markForCheck(); + } + + onChange = (_: any): void => { }; + registerOnChange(fn: (value: any) => void) { + this.onChange = fn; + } + + onTouch = (): void => { }; + registerOnTouched(fn: () => void) { + this.onTouch = fn; + } +} diff --git a/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle.module.ts b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle.module.ts new file mode 100644 index 00000000..db27249b --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle.module.ts @@ -0,0 +1,18 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { SfngToggleSwitchComponent } from "./toggle-switch"; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ], + declarations: [ + SfngToggleSwitchComponent, + ], + exports: [ + SfngToggleSwitchComponent, + ] +}) +export class SfngToggleSwitchModule { } diff --git a/desktop/angular/projects/safing/ui/src/lib/tooltip/_tooltip-component.scss b/desktop/angular/projects/safing/ui/src/lib/tooltip/_tooltip-component.scss new file mode 100644 index 00000000..ff90d82a --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tooltip/_tooltip-component.scss @@ -0,0 +1,5 @@ +sfng-tooltip-container { + @apply relative block; + + max-width: 16rem; +} diff --git a/desktop/angular/projects/safing/ui/src/lib/tooltip/index.ts b/desktop/angular/projects/safing/ui/src/lib/tooltip/index.ts new file mode 100644 index 00000000..fb071730 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tooltip/index.ts @@ -0,0 +1,3 @@ +export * from './tooltip'; +export * from './tooltip.module'; + diff --git a/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.html b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.html new file mode 100644 index 00000000..ad68d95c --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.html @@ -0,0 +1,6 @@ +
+ {{ message }} + +
diff --git a/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.ts b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.ts new file mode 100644 index 00000000..a206d5b3 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.ts @@ -0,0 +1,139 @@ +import { animate, AnimationEvent, style, transition, trigger } from "@angular/animations"; +import { OverlayRef } from "@angular/cdk/overlay"; +import { TemplatePortal } from "@angular/cdk/portal"; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Inject, InjectionToken, OnDestroy, TemplateRef, ViewContainerRef } from "@angular/core"; +import { SfngTooltipDirective } from "./tooltip"; + +export const SFNG_TOOLTIP_CONTENT = new InjectionToken>('SFNG_TOOLTIP_CONTENT'); +export const SFNG_TOOLTIP_OVERLAY = new InjectionToken('SFNG_TOOLTIP_OVERLAY'); + +@Component({ + selector: 'sfng-tooltip-container', + templateUrl: './tooltip-component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger( + 'moveInOut', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translate{{ what }}({{ value }}) scale(0.75)' }), + animate('.1s ease-in', + style({ opacity: 1, transform: 'translate{{ what }}(0%) scale(1)' })) + ], + { params: { what: 'Y', value: '-8px' } } // default parameters + ), + transition( + ':leave', + [ + style({ opacity: 1 }), + animate('.1s ease-out', + style({ opacity: 0, transform: 'translate{{ what }}({{ value }}) scale(0.75)' })) + ], + { params: { what: 'Y', value: '8px' } } // default parameters + ) + ] + )] + +}) +export class SfngTooltipComponent implements AfterViewInit, OnDestroy { + /** + * Adds snfg-tooltip-instance class to the host element. + * This is used as a selector in the FlexibleConnectedPosition stragegy + * to set a transform-origin. That origin is then used for the "arrow" anchor + * placement. + */ + @HostBinding('class.sfng-tooltip-instance') + _hostClass = true; + + /** + * Used to clear the "hide" timeout when the cursor moves from the the origin + * into the tooltip content. + * This is required if the tooltip contains rich and likely clickable content. + */ + @HostListener('mouseenter') + onMouseEnter() { this.directive.show() } + + /** + * If the tooltip is visible because the user moved inside the tooltip-component + * (see comment above) then we need to handle a mouse-leave event as well. + */ + @HostListener('mouseleave') + onMouseLeave() { this.directive.hide() } + + what = 'Y'; + value = '8px' + transformOrigin = ''; + + _appAnimate = false; + + private observer: MutationObserver | null = null; + + /** Message is the tooltip message to display in case tooltipContent is a string */ + message = ''; + + /** Portal is the tooltip content to display in case tooltipContent is a template reference */ + portal: TemplatePortal | null = null; + + constructor( + @Inject(SFNG_TOOLTIP_CONTENT) tooltipContent: string | TemplateRef, + @Inject(SFNG_TOOLTIP_OVERLAY) public overlayRef: OverlayRef, + private directive: SfngTooltipDirective, + private elementRef: ElementRef, + private cdr: ChangeDetectorRef, + private viewContainer: ViewContainerRef + ) { + if (tooltipContent instanceof TemplateRef) { + this.portal = new TemplatePortal(tooltipContent, this.viewContainer) + } else { + this.message = tooltipContent; + } + } + + dispose() { + this._appAnimate = false; + this.cdr.markForCheck(); + } + + animationDone(event: AnimationEvent) { + if (event.toState === 'void') { + this.overlayRef.dispose(); + } + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + } + + ngAfterViewInit(): void { + this.observer = new MutationObserver(mutations => { + this.transformOrigin = this.elementRef.nativeElement.style.transformOrigin; + if (!this.transformOrigin) { + return; + } + + const [x, y] = this.transformOrigin.split(" "); + if (x === 'center') { + this.what = 'Y' + if (y === 'top') { + this.value = '-8px' + } else { + this.value = '8px' + } + } else { + this.what = 'X' + if (x === 'left') { + this.value = '-8px' + } else { + this.value = '8px' + } + } + + this._appAnimate = true; + this.cdr.detectChanges(); + }); + this.observer.observe(this.elementRef.nativeElement, { attributes: true, attributeFilter: ['style'] }) + } +} + diff --git a/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.module.ts b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.module.ts new file mode 100644 index 00000000..49bd0a14 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.module.ts @@ -0,0 +1,23 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { PortalModule } from "@angular/cdk/portal"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { SfngTooltipDirective } from "./tooltip"; +import { SfngTooltipComponent } from "./tooltip-component"; + +@NgModule({ + imports: [ + PortalModule, + OverlayModule, + CommonModule, + ], + declarations: [ + SfngTooltipDirective, + SfngTooltipComponent + ], + exports: [ + SfngTooltipDirective + ] +}) +export class SfngTooltipModule { } + diff --git a/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.ts b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.ts new file mode 100644 index 00000000..032e6bec --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.ts @@ -0,0 +1,244 @@ +/* eslint-disable @angular-eslint/no-input-rename */ +import { coerceNumberProperty } from "@angular/cdk/coercion"; +import { ConnectedPosition, Overlay, OverlayRef, PositionStrategy } from "@angular/cdk/overlay"; +import { ComponentPortal } from "@angular/cdk/portal"; +import { ComponentRef, Directive, ElementRef, HostListener, Injector, Input, isDevMode, OnChanges, OnDestroy, OnInit, TemplateRef } from "@angular/core"; +import { Subject } from "rxjs"; +import { SfngTooltipComponent, SFNG_TOOLTIP_CONTENT, SFNG_TOOLTIP_OVERLAY } from "./tooltip-component"; + +/** The allowed tooltip positions. */ +export type SfngTooltipPosition = 'left' | 'right' | 'bottom' | 'top'; + +@Directive({ + selector: '[sfng-tooltip],[snfgTooltip]', +}) +export class SfngTooltipDirective implements OnInit, OnDestroy, OnChanges { + /** Used to control the visibility of the tooltip */ + private attach$ = new Subject(); + + /** Holds a reference to the tooltip overlay */ + private tooltipRef: ComponentRef | null = null; + + /** + * A reference to a timeout created by setTimeout used to debounce + * displaying the tooltip + */ + private debouncer: any | null = null; + + constructor( + private overlay: Overlay, + private injector: Injector, + private originRef: ElementRef, + ) { } + + @HostListener('mouseenter') + show(delay = this.delay) { + if (this.debouncer !== null) { + clearTimeout(this.debouncer); + } + + this.debouncer = setTimeout(() => { + this.debouncer = null; + this.attach$.next(true); + }, delay); + } + + @HostListener('mouseleave') + hide(delay = this.delay / 2) { + // if we're currently debouncing a "show" than + // we should clear that out to avoid re-attaching + // the tooltip right after we disposed it. + if (this.debouncer !== null) { + clearTimeout(this.debouncer); + this.debouncer = null; + } + + this.debouncer = setTimeout(() => { + this.attach$.next(false); + this.debouncer = null; + }, delay); + } + + /** Debounce delay before showing the tooltip */ + @Input('sfngTooltipDelay') + set delay(v: any) { + this._delay = coerceNumberProperty(v); + } + get delay() { return this._delay } + private _delay = 500; + + /** An additional offset between the tooltip overlay and the origin centers */ + @Input('sfngTooltipOffset') + set offset(v: any) { + this._offset = coerceNumberProperty(v); + } + private _offset: number | null = 8; + + /** The actual content that should be displayed in the tooltip overlay. */ + @Input('sfngTooltip') + @Input('sfng-tooltip') + tooltipContent: string | TemplateRef | null = null; + + @Input('snfgTooltipPosition') + position: ConnectedPosition | SfngTooltipPosition | (SfngTooltipPosition | ConnectedPosition)[] | 'any' = 'any'; + + ngOnInit() { + this.attach$ + .subscribe(attach => { + if (attach) { + this.createTooltip(); + return; + } + if (!!this.tooltipRef) { + this.tooltipRef.instance.dispose(); + this.tooltipRef = null; + } + }) + } + + ngOnDestroy(): void { + this.attach$.next(false); + this.attach$.complete(); + } + + ngOnChanges(): void { + // if the tooltip content has be set to null and we're still + // showing the tooltip we treat that as an attempt to hide. + if (this.tooltipContent === null && !!this.tooltipRef) { + this.hide(); + } + } + + /** Creates the actual tooltip overlay */ + private createTooltip() { + // there's nothing to do if the tooltip is still active. + if (!!this.tooltipRef) { + return; + } + + // support disabling the tooltip by passing "null" for + // the content. + if (this.tooltipContent === null) { + return; + } + + const position = this.buildPositionStrategy(); + + const overlayRef = this.overlay.create({ + positionStrategy: position, + scrollStrategy: this.overlay.scrollStrategies.close(), + disposeOnNavigation: true, + }); + + // make sure we close the tooltip if the user clicks on our + // originRef. + overlayRef.outsidePointerEvents() + .subscribe(() => this.hide()); + + overlayRef.attachments() + .subscribe(() => { + if (!overlayRef) { + return + } + overlayRef.updateSize({}); + overlayRef.updatePosition(); + }) + + // create a component portal for the tooltip component + // and attach it to our newly created overlay. + const portal = this.getOverlayPortal(overlayRef); + this.tooltipRef = overlayRef.attach(portal); + } + + private getOverlayPortal(ref: OverlayRef): ComponentPortal { + const inj = Injector.create({ + providers: [ + { provide: SFNG_TOOLTIP_CONTENT, useValue: this.tooltipContent }, + { provide: SFNG_TOOLTIP_OVERLAY, useValue: ref }, + ], + parent: this.injector, + name: 'SfngTooltipDirective' + }) + + const portal = new ComponentPortal( + SfngTooltipComponent, + undefined, + inj + ) + + return portal; + } + + /** Builds a FlexibleConnectedPositionStrategy for the tooltip overlay */ + private buildPositionStrategy(): PositionStrategy { + let pos = this.position; + if (pos === 'any') { + pos = ['top', 'bottom', 'right', 'left'] + } else if (!Array.isArray(pos)) { + pos = [pos]; + } + + let allowedPositions: ConnectedPosition[] = + pos.map(p => { + if (typeof p === 'string') { + return this.getAllowedConnectedPosition(p); + } + // this is already a ConnectedPosition + return p + }); + + let position = this.overlay.position() + .flexibleConnectedTo(this.originRef) + .withFlexibleDimensions(true) + .withPush(true) + .withPositions(allowedPositions) + .withGrowAfterOpen(true) + .withTransformOriginOn('.sfng-tooltip-instance') + + return position; + } + + private getAllowedConnectedPosition(type: SfngTooltipPosition): ConnectedPosition { + switch (type) { + case 'left': + return { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: - (this._offset || 0), + } + case 'right': + return { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: (this._offset || 0), + } + case 'top': + return { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: - (this._offset || 0), + } + case 'bottom': + return { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: (this._offset || 0), + } + default: + if (isDevMode()) { + throw new Error(`invalid value for SfngTooltipPosition: ${type}`) + } + // fallback to "right" + return this.getAllowedConnectedPosition('right') + } + } +} + diff --git a/desktop/angular/projects/safing/ui/src/lib/ui.module.ts b/desktop/angular/projects/safing/ui/src/lib/ui.module.ts new file mode 100644 index 00000000..e1f772ae --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/lib/ui.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { SfngAccordionModule } from './accordion'; + + +@NgModule({ + exports: [ + SfngAccordionModule + ] +}) +export class UiModule { } diff --git a/desktop/angular/projects/safing/ui/src/public-api.ts b/desktop/angular/projects/safing/ui/src/public-api.ts new file mode 100644 index 00000000..e07d6adf --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/public-api.ts @@ -0,0 +1,16 @@ +/* + * Public API Surface of ui + */ + +export * from './lib/accordion'; +export * from './lib/dialog'; +export * from './lib/dropdown'; +export * from './lib/overlay-stepper'; +export * from './lib/pagination'; +export * from './lib/select'; +export * from './lib/tabs'; +export * from './lib/tipup'; +export * from './lib/toggle-switch'; +export * from './lib/tooltip'; +export * from './lib/ui.module'; + diff --git a/desktop/angular/projects/safing/ui/src/test.ts b/desktop/angular/projects/safing/ui/src/test.ts new file mode 100644 index 00000000..ceee7e40 --- /dev/null +++ b/desktop/angular/projects/safing/ui/src/test.ts @@ -0,0 +1,16 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; +import 'zone.js'; +import 'zone.js/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { teardown: { destroyAfterEach: true } }, +); diff --git a/desktop/angular/projects/safing/ui/theming.scss b/desktop/angular/projects/safing/ui/theming.scss new file mode 100644 index 00000000..9c5bb3c9 --- /dev/null +++ b/desktop/angular/projects/safing/ui/theming.scss @@ -0,0 +1,8 @@ +@import "./src/lib/select/select"; +@import "./src/lib/dialog/dialog"; +@import "./src/lib/pagination/pagination"; +@import "./src/lib/tabs/tab-group"; +@import "./src/lib/tipup/tipup"; +@import "./src/lib/tooltip/tooltip-component"; +@import "./src/lib/toggle-switch/toggle-switch"; +@import "./src/lib/dialog/confirm.dialog"; diff --git a/desktop/angular/projects/safing/ui/tsconfig.lib.json b/desktop/angular/projects/safing/ui/tsconfig.lib.json new file mode 100644 index 00000000..703cd4fd --- /dev/null +++ b/desktop/angular/projects/safing/ui/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": [ + "dom", + "es2018" + ] + }, + "exclude": [ + "src/test.ts", + "**/*.spec.ts" + ] +} diff --git a/desktop/angular/projects/safing/ui/tsconfig.lib.prod.json b/desktop/angular/projects/safing/ui/tsconfig.lib.prod.json new file mode 100644 index 00000000..71b135f6 --- /dev/null +++ b/desktop/angular/projects/safing/ui/tsconfig.lib.prod.json @@ -0,0 +1,7 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, +} diff --git a/desktop/angular/projects/safing/ui/tsconfig.spec.json b/desktop/angular/projects/safing/ui/tsconfig.spec.json new file mode 100644 index 00000000..85392ee8 --- /dev/null +++ b/desktop/angular/projects/safing/ui/tsconfig.spec.json @@ -0,0 +1,17 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "files": [ + "src/test.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/desktop/angular/projects/tauri-builtin/src/app/app.component.html b/desktop/angular/projects/tauri-builtin/src/app/app.component.html new file mode 100644 index 00000000..c8897e1e --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/app/app.component.html @@ -0,0 +1,105 @@ +
+ + + +
+

Safing

+

+ Portmaster +

+
+
+ +
+ + + Connecting to System Service ... + + + + Connecting to System Service ... + + + + + Portmaster System Service is not running: + + + + + + + + + + + + Failed to find Portmaster System Service. +
+ Please reinstall the application. +
+ + +
+ + + + + + + + Your System Service Manager is not supported. Please make sure Portmaster is running. + + + + + + + + + + Your System Service Manager is not supported. Please make sure Portmaster is running. + + + + Unknown error: {{ status }} +
\ No newline at end of file diff --git a/desktop/angular/projects/tauri-builtin/src/app/app.component.ts b/desktop/angular/projects/tauri-builtin/src/app/app.component.ts new file mode 100644 index 00000000..b39cd515 --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/app/app.component.ts @@ -0,0 +1,52 @@ +import { OnInit, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ServiceManagerStatus, TauriIntegrationService } from 'src/app/integration/taur-app'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [CommonModule], + templateUrl: './app.component.html', + styles: [ + ` + :host { + @apply block w-screen h-screen bg-background; + } + + #logo svg { + @apply absolute w-20; + } + `, + ], +}) +export class AppComponent implements OnInit { + private tauri = inject(TauriIntegrationService); + + status: ServiceManagerStatus | string | null = null; + + getHelp() { + this.tauri.openExternal("https://wiki.safing.io/en/Portmaster/App") + } + + startService() { + this.tauri.startService() + .then(() => this.getStatus()) + .catch(err => { + this.status = err.error; + }); + } + + getStatus() { + this.tauri.getServiceManagerStatus() + .then(result => { + this.status = result; + }) + .catch(err => { + this.status = err.error; + }) + } + + ngOnInit() { + this.getStatus(); + } +} diff --git a/desktop/angular/projects/tauri-builtin/src/app/app.config.ts b/desktop/angular/projects/tauri-builtin/src/app/app.config.ts new file mode 100644 index 00000000..2b4aa00c --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/app/app.config.ts @@ -0,0 +1,12 @@ +import { ApplicationConfig } from '@angular/core'; +import { TauriIntegrationService } from 'src/app/integration/taur-app'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: TauriIntegrationService, + useClass: TauriIntegrationService, + deps: [] + }, + ], +}; diff --git a/desktop/angular/projects/tauri-builtin/src/assets b/desktop/angular/projects/tauri-builtin/src/assets new file mode 120000 index 00000000..2978ef39 --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/assets @@ -0,0 +1 @@ +../../../assets \ No newline at end of file diff --git a/desktop/angular/projects/tauri-builtin/src/favicon.ico b/desktop/angular/projects/tauri-builtin/src/favicon.ico new file mode 100644 index 00000000..997406ad Binary files /dev/null and b/desktop/angular/projects/tauri-builtin/src/favicon.ico differ diff --git a/desktop/angular/projects/tauri-builtin/src/index.html b/desktop/angular/projects/tauri-builtin/src/index.html new file mode 100644 index 00000000..e99290a6 --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/index.html @@ -0,0 +1,13 @@ + + + + + TauriBuiltin + + + + + + + + diff --git a/desktop/angular/projects/tauri-builtin/src/main.ts b/desktop/angular/projects/tauri-builtin/src/main.ts new file mode 100644 index 00000000..35b00f34 --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/desktop/angular/projects/tauri-builtin/src/styles.scss b/desktop/angular/projects/tauri-builtin/src/styles.scss new file mode 100644 index 00000000..66a2c66c --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/src/styles.scss @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import "safing/ui/theming"; + +/** foboar **/ diff --git a/desktop/angular/projects/tauri-builtin/tsconfig.app.json b/desktop/angular/projects/tauri-builtin/tsconfig.app.json new file mode 100644 index 00000000..f12c6239 --- /dev/null +++ b/desktop/angular/projects/tauri-builtin/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "../../src/electron-app.d.ts"] +} diff --git a/desktop/angular/proxy.json b/desktop/angular/proxy.json new file mode 100644 index 00000000..c60a2a4c --- /dev/null +++ b/desktop/angular/proxy.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:817/", + "secure": false + } +} diff --git a/desktop/angular/src/app/app-routing.module.ts b/desktop/angular/src/app/app-routing.module.ts new file mode 100644 index 00000000..2324ae8b --- /dev/null +++ b/desktop/angular/src/app/app-routing.module.ts @@ -0,0 +1,68 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AppViewComponent } from './pages/app-view'; +import { DashboardPageComponent } from './pages/dashboard/dashboard.component'; +import { MonitorPageComponent } from './pages/monitor'; +import { SettingsComponent } from './pages/settings/settings'; +import { SpnPageComponent } from './pages/spn'; +import { SupportPageComponent } from './pages/support'; +import { SupportFormComponent } from './pages/support/form'; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + redirectTo: 'dashboard', + }, + { + path: 'settings', + component: SettingsComponent, + }, + { + path: 'app', + pathMatch: 'full', + redirectTo: 'app/overview', + }, + { + path: 'app/overview', + component: AppViewComponent, + }, + { + path: 'app/:source/:id', + component: AppViewComponent, + }, + { + path: 'monitor', + component: MonitorPageComponent, + }, + { + path: 'monitor/profile/:source/:profile', + redirectTo: 'monitor', + }, + { + path: 'support', + component: SupportPageComponent, + }, + { + path: 'support/:id', + component: SupportFormComponent, + }, + { + path: 'spn', + component: SpnPageComponent, + }, + { + path: '**', + redirectTo: 'dashboard' + }, + { + path: 'dashboard', + component: DashboardPageComponent + } +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes, { anchorScrolling: 'enabled' })], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/desktop/angular/src/app/app.component.html b/desktop/angular/src/app/app.component.html new file mode 100644 index 00000000..401b4a49 --- /dev/null +++ b/desktop/angular/src/app/app.component.html @@ -0,0 +1,53 @@ + + + +
+ + + + +
+ +
+ +
+
+ +

{{overlayText}}

+

...

+
+
diff --git a/desktop/angular/src/app/app.component.scss b/desktop/angular/src/app/app.component.scss new file mode 100644 index 00000000..52cb3a92 --- /dev/null +++ b/desktop/angular/src/app/app.component.scss @@ -0,0 +1,114 @@ +:host { + display: flex; + @apply bg-background; + @apply h-screen overflow-hidden; + + &>* { + flex-shrink: 0; + } +} + +app-navigation, +app-side-dash { + @apply border-r; + @apply border-cards-tertiary; + @apply bg-background; +} + +app-navigation { + @apply w-16; +} + +div.main { + flex-grow: 1; + flex-shrink: 1; + + display: flex; + flex-direction: column; + align-items: center; + @apply bg-background; + height: 100vh; + overflow: hidden; +} + +app-debug { + @apply border-l; + @apply border-cards-tertiary; + @apply bg-background; + + width: 30vw; + height: 100vh; + min-width: 350px; + top: 0px; + position: sticky; +} + +.loading { + z-index: 100; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + backdrop-filter: blur(10px); + background-color: rgba(#222222, 0.35); + + .message { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + flex-direction: column; + } + + svg { + width: 100%; + position: absolute; + top: 0; + left: 0; + } + + div.logo { + opacity: 0.8; + position: relative; + width: 10vh; + height: 10vh; + @apply mt-4; + } + + .spin { + animation-name: spin; + animation-duration: 3500ms; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + .reverse { + animation-name: spin-reverse; + } +} + + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + 0% { + transform: rotate(360deg); + } + + 100% { + transform: rotate(0deg); + } +} diff --git a/desktop/angular/src/app/app.component.spec.ts b/desktop/angular/src/app/app.component.spec.ts new file mode 100644 index 00000000..200892c0 --- /dev/null +++ b/desktop/angular/src/app/app.component.spec.ts @@ -0,0 +1,28 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule + ], + declarations: [ + AppComponent + ], + }).compileComponents(); + })); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have as title 'portmaster'`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('portmaster'); + }); +}); diff --git a/desktop/angular/src/app/app.component.ts b/desktop/angular/src/app/app.component.ts new file mode 100644 index 00000000..1ea813cd --- /dev/null +++ b/desktop/angular/src/app/app.component.ts @@ -0,0 +1,234 @@ +import { Overlay } from '@angular/cdk/overlay'; +import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, NgZone, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { Params, Router } from '@angular/router'; +import { PortapiService } from '@safing/portmaster-api'; +import { OverlayStepper, SfngDialogService, StepperRef } from '@safing/ui'; +import { BehaviorSubject, merge, Subject } from 'rxjs'; +import { debounceTime, filter, mergeMap, skip, startWith, take } from 'rxjs/operators'; +import { IntroModule } from './intro'; +import { NotificationsService, UIStateService } from './services'; +import { ActionIndicatorService } from './shared/action-indicator'; +import { fadeInAnimation, fadeOutAnimation } from './shared/animations'; +import { ExitService } from './shared/exit-screen'; +import { SfngNetquerySearchOverlayComponent } from './shared/netquery/search-overlay'; +import { INTEGRATION_SERVICE, IntegrationService } from './integration'; +import { TauriIntegrationService } from './integration/taur-app'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + animations: [ + fadeInAnimation, + fadeOutAnimation, + ] +}) +export class AppComponent implements OnInit, AfterViewInit { + readonly connected = this.portapi.connected$.pipe( + debounceTime(250), + startWith(false) + ); + title = 'portmaster'; + + /** The current status of the side dash as emitted by the navigation component */ + sideDashStatus: 'collapsed' | 'expanded' = 'expanded'; + + /** Whether or not the side-dash is in overlay mode */ + sideDashOverlay = false; + + /** The MQL to watch for screen size changes. */ + private mql!: MediaQueryList; + + /** Emits when the side-dash is opened or closed in non-overlay mode */ + private sideDashOpen = new BehaviorSubject(false); + + /** Used to emit when the window size changed */ + windowResizeChange = new Subject(); + + get sideDashOpen$() { return this.sideDashOpen.asObservable() } + + get showOverlay$() { return this.exitService.showOverlay$ } + + get onContentSizeChange$() { + return merge( + this.windowResizeChange, + this.sideDashOpen$ + ) + .pipe( + startWith(undefined), + debounceTime(100), + ) + } + + @ViewChild('mainContent', { read: ElementRef, static: true }) + mainContent!: ElementRef; + + @HostListener('window:resize') + onWindowResize() { + this.windowResizeChange.next(); + } + + @HostListener('document:keydown', ['$event']) + onKeyDown(event: KeyboardEvent) { + if (event.key === ' ' && event.ctrlKey) { + this.dialog.create( + SfngNetquerySearchOverlayComponent, + { + positionStrategy: this.overlay + .position() + .global() + .centerHorizontally() + .top('1rem'), + backdrop: 'light', + autoclose: true, + } + ) + return; + } + } + + constructor( + public ngZone: NgZone, + public portapi: PortapiService, + public changeDetectorRef: ChangeDetectorRef, + private router: Router, + private exitService: ExitService, + private overlayStepper: OverlayStepper, + private dialog: SfngDialogService, + private overlay: Overlay, + private stateService: UIStateService, + private renderer2: Renderer2, + @Inject(INTEGRATION_SERVICE) private integration: IntegrationService, + ) { + (window as any).portapi = portapi; + } + + onSideDashChange(state: 'expanded' | 'collapsed' | 'force-overlay') { + if (state === 'force-overlay') { + state = 'expanded'; + if (!this.sideDashOverlay) { + this.sideDashOverlay = true; + } + } else { + this.sideDashOverlay = this.mql.matches; + } + + this.sideDashStatus = state; + + if (!this.sideDashOverlay) { + this.sideDashOpen.next(this.sideDashStatus === 'expanded') + } + } + + ngOnInit() { + // default breakpoints used by tailwindcss + const minContentWithBp = [ + 640, // sfng-sm: + 768, // sfng-md: + 1024, // sfng-lg: + 1280, // sfng-xl: + 1536 // sfng-2xl: + ] + + // prepare our breakpoint listeners and add the classes to our main element + merge( + this.windowResizeChange, + this.sideDashOpen$ + ) + .pipe( + startWith(undefined), + debounceTime(100), + ) + .subscribe(() => { + const rect = (this.mainContent.nativeElement as HTMLElement).getBoundingClientRect(); + + minContentWithBp.forEach((bp, idx) => { + if (rect.width >= bp) { + this.renderer2.addClass(this.mainContent.nativeElement, `min-width-${bp}px`) + } else { + this.renderer2.removeClass(this.mainContent.nativeElement, `min-width-${bp}px`) + } + }) + + this.changeDetectorRef.markForCheck(); + }) + + // force a reload of the current route if we reconnected to + // portmaster. This ensures we'll refresh any data that's currently + // displayed. + this.connected + .pipe( + filter(connected => !!connected), + skip(1), + ) + .subscribe(async () => { + const location = new URL(window.location.toString()); + + const params: Params = {} + location.searchParams.forEach((value, key) => { + params[key] = [ + ...(params[key] || []), + value, + ] + }) + + await this.router.navigateByUrl('/', { skipLocationChange: true }) + this.router.navigate([location.pathname], { + queryParams: params, + }); + }) + + this.stateService.uiState() + .pipe(take(1)) + .subscribe(state => { + if (!state.introScreenFinished) { + this.showIntro(); + } + }) + + this.mql = window.matchMedia('(max-width: 1200px)'); + this.sideDashOverlay = this.mql.matches; + + this.mql.addEventListener('change', () => { + this.sideDashOverlay = this.mql.matches; + + if (!this.sideDashOverlay) { + this.sideDashOpen.next(this.sideDashStatus === 'expanded') + } + }) + } + + ngAfterViewInit(): void { + this.sideDashOpen.next(this.sideDashStatus !== 'collapsed') + + if (this.integration instanceof TauriIntegrationService) { + let tauri = this.integration; + + tauri.shouldShow() + .then(show => { + console.log("should open window: ", show) + if (show) { + tauri.openApp(); + } + }); + } + } + + showIntro(): StepperRef { + const stepperRef = this.overlayStepper.create(IntroModule.Stepper) + + stepperRef.onFinish.subscribe(() => { + this.stateService.uiState() + .pipe( + take(1), + mergeMap(state => this.stateService.saveState({ + ...state, + introScreenFinished: true + })) + ) + .subscribe(); + }) + + return stepperRef; + } +} diff --git a/desktop/angular/src/app/app.module.ts b/desktop/angular/src/app/app.module.ts new file mode 100644 index 00000000..c90aaec5 --- /dev/null +++ b/desktop/angular/src/app/app.module.ts @@ -0,0 +1,240 @@ +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { CdkTableModule } from '@angular/cdk/table'; +import { CommonModule, registerLocaleData } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; + +import { APP_INITIALIZER, LOCALE_ID, NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { FaIconLibrary, FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faGithub } from '@fortawesome/free-brands-svg-icons'; +import { far } from '@fortawesome/free-regular-svg-icons'; +import { fas } from '@fortawesome/free-solid-svg-icons'; +import { ConfigService, PortmasterAPIModule, StringSetting, getActualValue } from '@safing/portmaster-api'; +import { OverlayStepperModule, SfngAccordionModule, SfngDialogModule, SfngDropDownModule, SfngPaginationModule, SfngSelectModule, SfngTipUpModule, SfngToggleSwitchModule, SfngTooltipModule, TabModule, UiModule } from '@safing/ui'; +import MyYamlFile from 'js-yaml-loader!../i18n/helptexts.yaml'; +import * as i18n from 'ng-zorro-antd/i18n'; +import { MarkdownModule } from 'ngx-markdown'; +import { firstValueFrom } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +import { IntroModule } from './intro'; +import { NavigationComponent } from './layout/navigation/navigation'; +import { SideDashComponent } from './layout/side-dash/side-dash'; +import { AppOverviewComponent, AppViewComponent, QuickSettingInternetButtonComponent } from './pages/app-view'; +import { QsHistoryComponent } from './pages/app-view/qs-history/qs-history.component'; +import { QuickSettingSelectExitButtonComponent } from './pages/app-view/qs-select-exit/qs-select-exit'; +import { QuickSettingUseSPNButtonComponent } from './pages/app-view/qs-use-spn/qs-use-spn'; +import { DashboardPageComponent } from './pages/dashboard/dashboard.component'; +import { FeatureCardComponent } from './pages/dashboard/feature-card/feature-card.component'; +import { MonitorPageComponent } from './pages/monitor'; +import { SettingsComponent } from './pages/settings/settings'; +import { SPNModule } from './pages/spn/spn.module'; +import { SupportPageComponent } from './pages/support'; +import { SupportFormComponent } from './pages/support/form'; +import { NotificationsService } from './services'; +import { ActionIndicatorModule } from './shared/action-indicator'; +import { SfngAppIconModule } from './shared/app-icon'; +import { ConfigModule } from './shared/config'; +import { CountIndicatorModule } from './shared/count-indicator'; +import { CountryFlagModule } from './shared/country-flag'; +import { EditProfileDialog } from './shared/edit-profile-dialog'; +import { ExitScreenComponent } from './shared/exit-screen/exit-screen'; +import { ExpertiseModule } from './shared/expertise/expertise.module'; +import { ExternalLinkDirective } from './shared/external-link.directive'; +import { FeatureScoutComponent } from './shared/feature-scout'; +import { SfngFocusModule } from './shared/focus'; +import { FuzzySearchPipe } from './shared/fuzzySearch'; +import { LoadingComponent } from './shared/loading'; +import { SfngMenuModule } from './shared/menu'; +import { SfngMultiSwitchModule } from './shared/multi-switch'; +import { NetqueryModule } from './shared/netquery'; +import { NetworkScoutComponent } from './shared/network-scout'; +import { NotificationListComponent } from './shared/notification-list/notification-list.component'; +import { NotificationComponent } from './shared/notification/notification'; +import { CommonPipesModule } from './shared/pipes'; +import { ProcessDetailsDialogComponent } from './shared/process-details-dialog'; +import { PromptListComponent } from './shared/prompt-list/prompt-list.component'; +import { SecurityLockComponent } from './shared/security-lock'; +import { SPNAccountDetailsComponent } from './shared/spn-account-details'; +import { SPNLoginComponent } from './shared/spn-login'; +import { SPNStatusComponent } from './shared/spn-status'; +import { PilotWidgetComponent } from './shared/status-pilot'; +import { PlaceholderComponent } from './shared/text-placeholder'; +import { DashboardWidgetComponent } from './pages/dashboard/dashboard-widget/dashboard-widget.component'; +import { MergeProfileDialogComponent } from './pages/app-view/merge-profile-dialog/merge-profile-dialog.component'; +import { AppInsightsComponent } from './pages/app-view/app-insights/app-insights.component'; +import { INTEGRATION_SERVICE, integrationServiceFactory } from './integration'; +import { SupportProgressDialogComponent } from './pages/support/progress-dialog'; + +function loadAndSetLocaleInitializer(configService: ConfigService) { + return async function () { + let angularLocaleID = 'en-GB'; + let nzLocaleID: string = 'en_GB'; + + try { + const setting = await firstValueFrom(configService.get("core/locale")) + + const currentValue = getActualValue(setting as StringSetting); + switch (currentValue) { + case 'en-US': + angularLocaleID = 'en-US' + nzLocaleID = 'en_US' + break; + case 'en-GB': + angularLocaleID = 'en-GB' + nzLocaleID = 'en_GB' + break; + + default: + console.error(`Unsupported locale value: ${currentValue}, defaulting to en-GB`) + } + } catch (err) { + console.error(`failed to get locale setting, using default en-GB:`, err) + } + + try { + // Get name of module. + let localeModuleID = angularLocaleID; + if (localeModuleID == "en-US") { + localeModuleID = "en"; + } + + /* webpackInclude: /(en|en-GB)\.mjs$/ */ + /* webpackChunkName: "./l10n-base/[request]"*/ + await import(`../../node_modules/@angular/common/locales/${localeModuleID}.mjs`) + .then(locale => { + registerLocaleData(locale.default) + + localeConfig.localeId = angularLocaleID; + localeConfig.nzLocale = (i18n as any)[nzLocaleID]; + }) + } catch (err) { + console.error(`failed to load locale module for ${angularLocaleID}:`, err) + } + } +} + +const localeConfig = { + nzLocale: i18n.en_GB, + localeId: 'en-GB' +} + +@NgModule({ + declarations: [ + AppComponent, + NotificationComponent, + SettingsComponent, + MonitorPageComponent, + SideDashComponent, + NavigationComponent, + PilotWidgetComponent, + NotificationListComponent, + PromptListComponent, + FuzzySearchPipe, + AppViewComponent, + QuickSettingInternetButtonComponent, + QuickSettingUseSPNButtonComponent, + QuickSettingSelectExitButtonComponent, + AppOverviewComponent, + PlaceholderComponent, + LoadingComponent, + ExternalLinkDirective, + ExitScreenComponent, + SupportPageComponent, + SupportFormComponent, + SecurityLockComponent, + SPNStatusComponent, + FeatureScoutComponent, + SPNLoginComponent, + SPNAccountDetailsComponent, + NetworkScoutComponent, + EditProfileDialog, + ProcessDetailsDialogComponent, + QsHistoryComponent, + DashboardPageComponent, + DashboardWidgetComponent, + FeatureCardComponent, + MergeProfileDialogComponent, + AppInsightsComponent, + SupportProgressDialogComponent + ], + imports: [ + BrowserModule, + CommonModule, + BrowserAnimationsModule, + FormsModule, + ReactiveFormsModule, + AppRoutingModule, + FontAwesomeModule, + OverlayModule, + PortalModule, + CdkTableModule, + DragDropModule, + HttpClientModule, + MarkdownModule.forRoot(), + ScrollingModule, + SfngAccordionModule, + TabModule, + SfngTipUpModule.forRoot(MyYamlFile, NotificationsService), + SfngTooltipModule, + ActionIndicatorModule, + SfngDialogModule, + OverlayStepperModule, + IntroModule, + SfngDropDownModule, + SfngSelectModule, + SfngMultiSwitchModule, + SfngMenuModule, + SfngFocusModule, + SfngToggleSwitchModule, + SfngPaginationModule, + SfngAppIconModule, + ExpertiseModule, + ConfigModule, + CountryFlagModule, + CountIndicatorModule, + NetqueryModule, + CommonPipesModule, + UiModule, + SPNModule, + PortmasterAPIModule.forRoot({ + httpAPI: environment.httpAPI, + websocketAPI: environment.portAPI, + }), + ], + bootstrap: [AppComponent], + providers: [ + { + provide: APP_INITIALIZER, useFactory: loadAndSetLocaleInitializer, deps: [ConfigService], multi: true + }, + { + provide: i18n.NZ_I18N, useFactory: () => { + console.log("nz-locale is set to", localeConfig.nzLocale) + return localeConfig.nzLocale + } + }, + { + provide: LOCALE_ID, useFactory: () => { + console.log("locale-id is set to", localeConfig.localeId) + return localeConfig.localeId + } + }, + { + provide: INTEGRATION_SERVICE, + useFactory: integrationServiceFactory + } + ] +}) +export class AppModule { + constructor(library: FaIconLibrary) { + library.addIconPacks(fas, far); + library.addIcons(faGithub) + } +} + diff --git a/desktop/angular/src/app/integration/browser.ts b/desktop/angular/src/app/integration/browser.ts new file mode 100644 index 00000000..82504c3b --- /dev/null +++ b/desktop/angular/src/app/integration/browser.ts @@ -0,0 +1,41 @@ +import { AppInfo, IntegrationService, ProcessInfo } from "./integration"; + +export class BrowserIntegrationService implements IntegrationService { + writeToClipboard(text: string): Promise { + if (!!navigator.clipboard) { + return navigator.clipboard.writeText(text); + } + + return Promise.reject(new Error(`Clipboard API not supported`)) + } + + openExternal(pathOrUrl: string): Promise { + window.open(pathOrUrl, '_blank') + + return Promise.resolve(); + } + + getInstallDir(): Promise { + return Promise.reject('Not supported in browser') + } + + getAppIcon(_: ProcessInfo): Promise { + return Promise.reject('Not supported in browser') + } + + getAppInfo(_: ProcessInfo): Promise { + return Promise.reject('Not supported in browser') + } + + exitApp(): Promise { + window.close(); + + return Promise.resolve(); + } + + onExitRequest(cb: () => void): () => void { + // nothing to do, there + return () => { } + } +} + diff --git a/desktop/angular/src/app/integration/electron.ts b/desktop/angular/src/app/integration/electron.ts new file mode 100644 index 00000000..71b63984 --- /dev/null +++ b/desktop/angular/src/app/integration/electron.ts @@ -0,0 +1,55 @@ +import { BrowserIntegrationService } from "./browser"; +import { AppInfo, ProcessInfo } from "./integration"; + +export class ElectronIntegrationService extends BrowserIntegrationService { + + openExternal(pathOrUrl: string): Promise { + if (!!window.app) { + return window.app.openExternal(pathOrUrl); + } + + return Promise.reject('No electron API available') + } + + getInstallDir(): Promise { + if (!!window.app) { + return window.app.getInstallDir() + } + + return Promise.reject('No electron API available') + } + + getAppIcon(info: ProcessInfo): Promise { + if (!!window.app) { + return window.app.getFileIcon(info.execPath) + } + + return Promise.reject('No electron API available') + } + + getAppInfo(_: ProcessInfo): Promise { + return Promise.reject('Not supported in electron') + } + + exitApp(): Promise { + if (!!window.app) { + window.app.exitApp(); + } + + return Promise.resolve(); + } + + onExitRequest(cb: () => void): () => void { + let listener = (event: MessageEvent) => { + if (event.data === 'on-app-close') { + cb(); + } + } + + window.addEventListener('message', listener); + + return () => { + window.removeEventListener('message', listener) + } + } +} diff --git a/desktop/angular/src/app/integration/factory.ts b/desktop/angular/src/app/integration/factory.ts new file mode 100644 index 00000000..419f3ea9 --- /dev/null +++ b/desktop/angular/src/app/integration/factory.ts @@ -0,0 +1,22 @@ +import { InjectionToken } from "@angular/core"; +import { BrowserIntegrationService } from "./browser"; +import { ElectronIntegrationService } from "./electron"; +import { IntegrationService } from "./integration"; +import { TauriIntegrationService } from "./taur-app"; + +export function integrationServiceFactory(): IntegrationService { + if ('__TAURI__' in window) { + console.log("[app] running under tauri") + return new TauriIntegrationService(); + } + + if ('app' in window) { + console.log("[app] running under electron") + return new ElectronIntegrationService(); + } + + console.log("[app] running in browser") + return new BrowserIntegrationService(); +} + +export const INTEGRATION_SERVICE = new InjectionToken('INTEGRATION_SERVICE'); diff --git a/desktop/angular/src/app/integration/index.ts b/desktop/angular/src/app/integration/index.ts new file mode 100644 index 00000000..de9e8105 --- /dev/null +++ b/desktop/angular/src/app/integration/index.ts @@ -0,0 +1,2 @@ +export * from './integration'; +export * from './factory'; diff --git a/desktop/angular/src/app/integration/integration.ts b/desktop/angular/src/app/integration/integration.ts new file mode 100644 index 00000000..ae426353 --- /dev/null +++ b/desktop/angular/src/app/integration/integration.ts @@ -0,0 +1,41 @@ + +export interface AppInfo { + app_name: string; + comment: string; + icon_dataurl: string; + icon_path: string; +} + +export interface ProcessInfo { + execPath: string; + cmdline: string; + pid: number; + matchingPath: string; +} + +export interface IntegrationService { + /** writeToClipboard copies text to the system clipboard */ + writeToClipboard(text: string): Promise; + + /** openExternal opens a file or URL in an external window */ + openExternal(pathOrUrl: string): Promise; + + /** Gets the path to the portmaster installation directory */ + getInstallDir(): Promise; + + /** Load application information (currently linux only) */ + getAppInfo(info: ProcessInfo): Promise; + + /** Loads the application icon as a dataurl */ + getAppIcon(info: ProcessInfo): Promise; + + /** Closes the application, does not return */ + exitApp(): Promise; + + /** Registers a listener for on-close requests. */ + onExitRequest(cb: () => void): () => void; +} + + + + diff --git a/desktop/angular/src/app/integration/taur-app.ts b/desktop/angular/src/app/integration/taur-app.ts new file mode 100644 index 00000000..f48c3499 --- /dev/null +++ b/desktop/angular/src/app/integration/taur-app.ts @@ -0,0 +1,216 @@ +import { AppInfo, IntegrationService, ProcessInfo } from "./integration"; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import { open } from '@tauri-apps/plugin-shell'; +import { listen, once } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core' +import { getCurrent, Window } from '@tauri-apps/api/window'; + +// Returns a new uuidv4. If crypto.randomUUID is not available it fals back to +// using Math.random(). While this is not as random as it should be it's still +// enough for our use-case here (which is just to generate a random response-id). +function uuid(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + // This one is not really random and not RFC compliant but serves enough for fallback + // purposes if the UI is opened in a browser that does not yet support randomUUID + console.warn('Using browser with lacking support for crypto.randomUUID()'); + + return Date.now().toString(36) + Math.random().toString(36).substring(2); +} + +function asyncInvoke(method: string, args: object): Promise { + return new Promise((resolve, reject) => { + const eventId = uuid(); + + once(eventId, (event) => { + if (typeof event.payload === 'object' && 'error' in event.payload) { + reject(event.payload); + return + }; + + resolve(event.payload); + }) + + invoke(method, { + ...args, + responseId: eventId, + }).catch((err: any) => { + console.error("tauri:invoke rejected: ", method, args, err); + reject(err) + }); + }) +} + +export type ServiceManagerStatus = 'Running' | 'Stopped' | 'NotFound' | 'unsupported service manager' | 'unsupported operating system'; + +export class TauriIntegrationService implements IntegrationService { + private withPrompts = false; + + constructor() { + this.shouldHandlePrompts() + .then(result => { + this.withPrompts = result; + }); + + // listen for the portmaster:show event that is emitted + // when tauri want's to tell us that we should make our + // window visible. + listen("portmaster:show", () => { + this.openApp(); + }) + } + + writeToClipboard(text: string): Promise { + return writeText(text); + } + + openExternal(pathOrUrl: string): Promise { + return open(pathOrUrl); + } + + getInstallDir(): Promise { + return Promise.reject("not yet supported in tauri") + } + + getAppInfo(info: ProcessInfo): Promise { + return asyncInvoke("plugin:portmaster|get_app_info", { + ...info, + }) + } + + getAppIcon(info: ProcessInfo): Promise { + return this.getAppInfo(info) + .then(info => info.icon_dataurl) + } + + exitApp(): Promise { + // we have two options here: + // - close(): close the native tauri window and release all resources of it. + // this has the disadvantage that if the user re-opens the window, + // it will take slightly longer because angular need to re-bootstrap + // the application. + // + // IMPORTANT: the angular application will automatically launch prompt + // windows via the tauri window interface. If we would call close(), + // those prompts wouldn't work anymore because the angular app would not + // be running in the background. + // + // - hide(): just set the window visibility to false. The advantage is that angular + // is still running and interacting with portmaster but it also means that + // we waste some system resources due to tauri window objects and the angular + // application. + + getCurrent().hide() + + return Promise.resolve(); + } + + // Tauri specific functions that are not defined in the IntegrationService interface. + // to use those methods you must check if integration instanceof TauriIntegrationService. + + async shouldShow(): Promise { + try { + const response = await invoke("plugin:portmaster|should_show"); + return response === "show"; + } catch (err) { + console.error(err); + return true; + } + } + + async shouldHandlePrompts(): Promise { + try { + const response = await invoke("plugin:portmaster|should_handle_prompts") + return response === "true" + } catch (err) { + console.error(err); + return false; + } + } + + get_state(key: string): Promise { + return invoke("plugin:portmaster|get_state"); + } + + set_state(key: string, value: string): Promise { + return invoke("plugin:portmaster|set_state", { + key, + value + }) + } + + getServiceManagerStatus(): Promise { + return asyncInvoke("plugin:portmaster|get_service_manager_status", {}) + } + + startService(): Promise { + return asyncInvoke("plugin:portmaster|start_service", {}); + } + + onExitRequest(cb: () => void): () => void { + let unlisten: () => void = () => { }; + + listen('exit-requested', () => { + cb(); + }).then(cleanup => { + unlisten = cleanup; + }) + + return () => { + unlisten(); + } + } + + openApp() { + Window.getByLabel("splash")?.close(); + const current = Window.getCurrent() + + current.isVisible() + .then(visible => { + if (!visible) { + current.show(); + current.setFocus(); + } + }); + } + + closePrompt() { + Window.getByLabel("prompt")?.close(); + } + + openPrompt() { + if (!this.withPrompts) { + return; + } + + if (Window.getByLabel("prompt")) { + return; + } + + let promptWindow = new Window("prompt", { + alwaysOnTop: true, + decorations: false, + minimizable: false, + maximizable: false, + resizable: false, + title: 'Portmaster Prompt', + visible: false, // the prompt marks it self as visible. + skipTaskbar: true, + closable: false, + center: true, + width: 600, + height: 300, + + // in src/main.ts we check the current location path + // and if it matches /prompt, we bootstrap the PromptEntryPointComponent + // instead of the AppComponent. + url: `http://${window.location.host}/prompt`, + } as any) + + promptWindow.once("tauri://error", (err) => { + console.error(err); + }); + } +} diff --git a/desktop/angular/src/app/intro/index.ts b/desktop/angular/src/app/intro/index.ts new file mode 100644 index 00000000..b0328f4b --- /dev/null +++ b/desktop/angular/src/app/intro/index.ts @@ -0,0 +1 @@ +export * from './intro.module'; diff --git a/desktop/angular/src/app/intro/intro.module.ts b/desktop/angular/src/app/intro/intro.module.ts new file mode 100644 index 00000000..8be0f36b --- /dev/null +++ b/desktop/angular/src/app/intro/intro.module.ts @@ -0,0 +1,36 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { SfngDropDownModule, SfngTipUpModule, StepperConfig } from "@safing/ui"; +import { ConfigModule } from "../shared/config"; +import { Step1WelcomeComponent } from "./step-1-welcome"; +import { Step2TrackersComponent } from "./step-2-trackers"; +import { Step3DNSComponent } from "./step-3-dns"; +import { Step4TipupsComponent } from "./step-4-tipups"; + +const steps = [ + Step1WelcomeComponent, + Step2TrackersComponent, + Step3DNSComponent, + Step4TipupsComponent, +] + +@NgModule({ + imports: [ + CommonModule, + OverlayModule, + FormsModule, + SfngDropDownModule, + ConfigModule, + SfngTipUpModule, + ], + declarations: steps +}) +export class IntroModule { + static Stepper: StepperConfig = { + steps: steps, + canAbort: (idx) => idx === 0, + } +} + diff --git a/desktop/angular/src/app/intro/step-1-welcome/index.ts b/desktop/angular/src/app/intro/step-1-welcome/index.ts new file mode 100644 index 00000000..4261731e --- /dev/null +++ b/desktop/angular/src/app/intro/step-1-welcome/index.ts @@ -0,0 +1 @@ +export * from './step-1-welcome'; diff --git a/desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.html b/desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.html new file mode 100644 index 00000000..2c8010c6 --- /dev/null +++ b/desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.html @@ -0,0 +1,14 @@ +

Portmaster Protects Your Privacy

+ +

+ Portmaster enhances your privacy with powerful defaults - no configuration needed! Of course you can customize + everything to your specific needs. +

+ + + + + diff --git a/desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.ts b/desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.ts new file mode 100644 index 00000000..e6af4a15 --- /dev/null +++ b/desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, Inject, TemplateRef, ViewChild } from "@angular/core"; +import { Step, StepRef, STEP_REF } from "@safing/ui"; +import { of } from "rxjs"; + +@Component({ + templateUrl: './step-1-welcome.html', + styleUrls: ['../step.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Step1WelcomeComponent implements Step { + validChange = of(true) + + readonly nextButtonLabel = 'Quick Setup'; + + @ViewChild('buttonTemplate', { static: true }) + buttonTemplate!: TemplateRef; + + constructor( + @Inject(STEP_REF) public stepRef: StepRef, + ) { } +} + diff --git a/desktop/angular/src/app/intro/step-2-trackers/index.ts b/desktop/angular/src/app/intro/step-2-trackers/index.ts new file mode 100644 index 00000000..60b7451b --- /dev/null +++ b/desktop/angular/src/app/intro/step-2-trackers/index.ts @@ -0,0 +1 @@ +export * from './step-2-trackers' diff --git a/desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.html b/desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.html new file mode 100644 index 00000000..bfd16cd1 --- /dev/null +++ b/desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.html @@ -0,0 +1,11 @@ +

Trackers Are Blocked System-Wide

+ +

Portmaster automatically blocks ads, trackers and malware hosts on your whole device. Portmaster knows what to block + through trusted domain lists, which are also used by Ad-Blockers in browsers, etc. You can always customize this in + the settings.

+ + + + + diff --git a/desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.ts b/desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.ts new file mode 100644 index 00000000..82d5be4d --- /dev/null +++ b/desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ConfigService, Setting } from "@safing/portmaster-api"; +import { Step } from "@safing/ui"; +import { of } from "rxjs"; +import { mergeMap } from "rxjs/operators"; +import { SaveSettingEvent } from "src/app/shared/config/generic-setting"; + +@Component({ + templateUrl: './step-2-trackers.html', + styleUrls: ['../step.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Step2TrackersComponent implements Step, OnInit { + private destroyRef = inject(DestroyRef); + + validChange = of(true) + + setting: Setting | null = null; + + constructor( + public configService: ConfigService, + public readonly elementRef: ElementRef, + private cdr: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.configService.get('filter/lists') + .pipe( + mergeMap(setting => { + this.setting = setting; + + return this.configService.watch(setting.Key) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(value => { + this.setting!.Value = value; + + this.cdr.markForCheck(); + }); + } + + saveSetting(event: SaveSettingEvent) { + this.configService.save(event.key, event.value) + .subscribe() + } +} diff --git a/desktop/angular/src/app/intro/step-3-dns/index.ts b/desktop/angular/src/app/intro/step-3-dns/index.ts new file mode 100644 index 00000000..85ccdb1a --- /dev/null +++ b/desktop/angular/src/app/intro/step-3-dns/index.ts @@ -0,0 +1 @@ +export * from './step-3-dns' diff --git a/desktop/angular/src/app/intro/step-3-dns/step-3-dns.html b/desktop/angular/src/app/intro/step-3-dns/step-3-dns.html new file mode 100644 index 00000000..aa17288a --- /dev/null +++ b/desktop/angular/src/app/intro/step-3-dns/step-3-dns.html @@ -0,0 +1,17 @@ +

Secure DNS for All Connections

+ +

Portmaster automatically encrypts all your DNS queries to safeguard them from prying eyes. Portmaster sets a default + provider, but you can always switch to a custom DNS-over-TLS provider in the global settings.

+ + +
+ + + +
+
diff --git a/desktop/angular/src/app/intro/step-3-dns/step-3-dns.ts b/desktop/angular/src/app/intro/step-3-dns/step-3-dns.ts new file mode 100644 index 00000000..a1dddae6 --- /dev/null +++ b/desktop/angular/src/app/intro/step-3-dns/step-3-dns.ts @@ -0,0 +1,106 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ConfigService, QuickSetting, Setting, applyQuickSetting } from "@safing/portmaster-api"; +import { Step } from "@safing/ui"; +import { of } from "rxjs"; +import { mergeMap } from "rxjs/operators"; +import { SaveSettingEvent } from "src/app/shared/config/generic-setting"; + +interface QuickSettingModel extends QuickSetting { + active: boolean; +} + +@Component({ + templateUrl: './step-3-dns.html', + styleUrls: ['../step.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Step3DNSComponent implements Step, OnInit { + private destroyRef = inject(DestroyRef); + + validChange = of(true) + + setting: Setting | null = null; + quickSettings: QuickSettingModel[] = []; + isCustomValue = false; + + constructor( + public configService: ConfigService, + public readonly elementRef: ElementRef, + private cdr: ChangeDetectorRef, + ) { } + + private getQuickSettings(): QuickSettingModel[] { + if (!this.setting) { + return []; + } + + let val = this.setting.Annotations["safing/portbase:ui:quick-setting"]; + if (val === undefined) { + return []; + } + + if (!Array.isArray(val)) { + return [{ + ...val, + active: false, + }] + } + + return val.map(v => ({ + ...v, + active: false, + })) + } + + ngOnInit(): void { + this.configService.get('dns/nameservers') + .pipe( + mergeMap(setting => { + this.setting = setting; + this.quickSettings = this.getQuickSettings(); + return this.configService.watch(setting.Key) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(value => { + this.setting!.Value = value; + + let hasActive = false; + this.isCustomValue = false; + + this.quickSettings.forEach(setting => { + if (this.setting?.Value !== undefined && JSON.stringify(this.setting.Value) === JSON.stringify(setting.Value)) { + setting.active = true; + hasActive = true; + } else { + setting.active = false; + } + }); + + if (!hasActive) { + if (this.setting?.Value !== undefined && JSON.stringify(this.setting!.Value) !== JSON.stringify(this.setting!.DefaultValue)) { + this.isCustomValue = true; + } else if (this.quickSettings.length > 0) { + this.quickSettings[0].active = true; + } + } + + this.cdr.markForCheck(); + }); + } + + saveSetting(event: SaveSettingEvent) { + this.configService.save(event.key, event.value) + .subscribe() + } + + applyQuickSetting(action: QuickSetting) { + const newValue = applyQuickSetting( + this.setting!.Value || this.setting!.DefaultValue, + action, + ) + this.configService.save(this.setting!.Key, newValue) + .subscribe(); + } +} diff --git a/desktop/angular/src/app/intro/step-4-tipups/index.ts b/desktop/angular/src/app/intro/step-4-tipups/index.ts new file mode 100644 index 00000000..02886c50 --- /dev/null +++ b/desktop/angular/src/app/intro/step-4-tipups/index.ts @@ -0,0 +1 @@ +export * from './step-4-tipups' diff --git a/desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.html b/desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.html new file mode 100644 index 00000000..f15afd36 --- /dev/null +++ b/desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.html @@ -0,0 +1,11 @@ +

Learn More as You Explore

+ +

Portmaster has a lot more to offer. When you decide to dive deeper you can always click on an information icon to + learn more about a certain feature. Look out for those!

+ +
+ Click Me! +
+ +
+
diff --git a/desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.ts b/desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.ts new file mode 100644 index 00000000..5b0463a1 --- /dev/null +++ b/desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { Step } from "@safing/ui"; +import { of } from "rxjs"; + +@Component({ + templateUrl: './step-4-tipups.html', + styleUrls: ['../step.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Step4TipupsComponent implements Step { + validChange = of(true) +} diff --git a/desktop/angular/src/app/intro/step.scss b/desktop/angular/src/app/intro/step.scss new file mode 100644 index 00000000..1d17d9a2 --- /dev/null +++ b/desktop/angular/src/app/intro/step.scss @@ -0,0 +1,11 @@ +:host { + @apply flex flex-col items-center justify-center; +} + +h1 { + @apply text-primary text-2xl font-medium capitalize text-center py-5; +} + +p { + @apply text-tertiary text-sm font-medium text-center; +} diff --git a/desktop/angular/src/app/layout/navigation/navigation.html b/desktop/angular/src/app/layout/navigation/navigation.html new file mode 100644 index 00000000..0359b2dd --- /dev/null +++ b/desktop/angular/src/app/layout/navigation/navigation.html @@ -0,0 +1,230 @@ +
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+ + + + +
+ + + + diff --git a/desktop/angular/src/app/layout/navigation/navigation.scss b/desktop/angular/src/app/layout/navigation/navigation.scss new file mode 100644 index 00000000..4683bec5 --- /dev/null +++ b/desktop/angular/src/app/layout/navigation/navigation.scss @@ -0,0 +1,98 @@ +:host { + height: 100vh; + top: 0px; + position: sticky; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + user-select: none; + + .logo-image { + @apply w-6 -top-3 -left-3 absolute; + position: absolute; + } + + svg { + &:not(.connected) { + animation-timing-function: cubic-bezier(0.445, 0.05, 0.55, 0.95); + + path.inner { + fill: theme('colors.info.red'); + } + } + } + + div.nav-list { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + } + + div.nav-lower-list { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + padding-bottom: 1.5rem; + } + + div.link { + @apply my-2; + + width: 2rem; + height: 2rem; + border-radius: 10px; + + display: flex; + justify-content: space-around; + align-items: center; + + cursor: pointer; + + & { + outline: none; + + svg, + fa-icon { + opacity: .5; + } + } + + &:target, + &.active { + background-color: #2c2c2c; + + svg, + fa-icon { + opacity: 1; + transform: scale(1.08); + } + } + + &:hover { + + svg, + fa-icon { + opacity: 1; + } + } + + svg, + fa-icon { + + &.dash, + &.spn, + &.monitor, + &.app, + &.help, + &.settings { + @apply text-white; + width: 1.1rem; + position: relative; + stroke: currentColor; + } + } + } +} diff --git a/desktop/angular/src/app/layout/navigation/navigation.ts b/desktop/angular/src/app/layout/navigation/navigation.ts new file mode 100644 index 00000000..4752301f --- /dev/null +++ b/desktop/angular/src/app/layout/navigation/navigation.ts @@ -0,0 +1,298 @@ +import { INTEGRATION_SERVICE, IntegrationService } from 'src/app/integration'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, OnInit, Output, inject } from '@angular/core'; +import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting } from '@safing/portmaster-api'; +import { tap } from 'rxjs/operators'; +import { AppComponent } from 'src/app/app.component'; +import { NotificationType, NotificationsService, StatusService, VersionStatus } from 'src/app/services'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations'; +import { ExitService } from 'src/app/shared/exit-screen'; +import { TauriIntegrationService } from 'src/app/integration/taur-app'; + +@Component({ + selector: 'app-navigation', + templateUrl: './navigation.html', + styleUrls: ['./navigation.scss'], + exportAs: 'navigation', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeOutAnimation, + ] +}) +export class NavigationComponent implements OnInit { + private readonly integration = inject(INTEGRATION_SERVICE); + + /** Emits the current portapi connection state on changes. */ + readonly connected$ = this.portapi.connected$; + + /** @private The available and selected resource versions. */ + versions: VersionStatus | null = null; + + /** Whether or not we have new, unseen notifications */ + hasNewNotifications = false; + + /** The color to use for the notifcation-available hint (dot) */ + notificationColor: string = 'text-green-300'; + + /** Whether or not we have new, unseen prompts */ + hasNewPrompts = false; + + /** Whether or not prompting is globally enabled. */ + globalPromptingEnabled = false; + + @Output() + sideDashChange = new EventEmitter<'collapsed' | 'expanded' | 'force-overlay'>(); + + /** Whether or not the side dash should be expanded or collapsed */ + sideDashStatus: 'collapsed' | 'expanded' = 'expanded'; + + constructor( + private portapi: PortapiService, + private exitService: ExitService, + private statusService: StatusService, + private configService: ConfigService, + private appComponent: AppComponent, + private debugAPI: DebugAPI, + private actionIndicator: ActionIndicatorService, + private notificationService: NotificationsService, + private spnService: SPNService, + private cdr: ChangeDetectorRef + ) { } + + dropDownPositions: ConnectedPosition[] = [ + { + originX: 'end', + originY: 'top', + overlayX: 'start', + overlayY: 'top' + } + ] + + ngOnInit() { + const mql = window.matchMedia('(max-width: 1200px)'); + + if (mql.matches) { + this.sideDashStatus = 'collapsed'; + this.sideDashChange.next(this.sideDashStatus); + } + + mql.addEventListener('change', () => { + if (mql.matches) { + this.sideDashStatus = 'collapsed'; + } else { + this.sideDashStatus = 'expanded'; + } + this.sideDashChange.next(this.sideDashStatus); + }) + + this.statusService.getVersions() + .subscribe(versions => { + this.versions = versions; + this.cdr.markForCheck(); + }); + + this.configService.watch('filter/defaultAction') + .subscribe(defaultAction => { + this.globalPromptingEnabled = defaultAction === 'ask'; + this.cdr.markForCheck(); + }) + + this.notificationService.new$ + .subscribe(notif => { + + + if (notif.some(n => n.Type === NotificationType.Prompt && n.EventID.startsWith("filter:prompt"))) { + this.hasNewPrompts = true; + + if (this.integration instanceof TauriIntegrationService) { + this.integration.openPrompt(); + } + } else { + this.hasNewPrompts = false; + + if (this.integration instanceof TauriIntegrationService) { + this.integration.closePrompt(); + } + } + + if (notif.some(n => !n.EventID.startsWith("filter:prompt"))) { + this.hasNewNotifications = true; + } else { + this.hasNewNotifications = false; + } + + if (notif.some(n => n.Type === NotificationType.Error)) { + this.notificationColor = 'text-red-300'; + } else if (notif.some(n => n.Type === NotificationType.Warning)) { + this.notificationColor = 'text-yellow-300'; + } else { + this.notificationColor = 'text-green-300'; + } + + this.cdr.markForCheck(); + }) + } + + toggleSideDash(event: MouseEvent) { + let notify: 'expanded' | 'collapsed' | 'force-overlay' = this.sideDashStatus; + + if (this.sideDashStatus === 'collapsed') { + this.sideDashStatus = 'expanded'; + notify = 'expanded'; + if (event.shiftKey) { + notify = 'force-overlay' + } + } else { + this.sideDashStatus = 'collapsed'; + notify = 'collapsed' + } + + this.sideDashChange.next(notify); + } + + /** + * @private + * Injects a ui/reload event and performs a complete + * reload of the window once the portmaster re-opened the + * UI bundle. + */ + reloadUI(_: Event) { + this.portapi.reloadUI() + .pipe( + tap(() => { + setTimeout(() => window.location.reload(), 1000) + }) + ) + .subscribe(this.actionIndicator.httpObserver( + 'Reloading UI ...', + 'Failed to Reload UI', + )) + } + + /** Re-initialize the SPN */ + reinitSPN(_: Event) { + this.portapi.reinitSPN() + .subscribe(this.actionIndicator.httpObserver( + 'Re-initialized SPN', + 'Failed to re-initialize the SPN' + )) + } + + /** Logs the user out of the SPN completely by purgin the user profile from the local storage */ + logoutCompletely(_: Event) { + this.spnService.logout(true) + .subscribe(this.actionIndicator.httpObserver( + 'Logout', + 'You have been logged out of the SPN completely.' + )) + } + + /** + * @private + * Clear the DNS name cache. + */ + clearDNSCache(_: Event) { + this.portapi.clearDNSCache() + .subscribe(this.actionIndicator.httpObserver( + 'DNS Cache Cleared', + 'Failed to Clear DNS Cache.', + )) + } + + cleanupHistory(_: Event) { + this.portapi.cleanupHistory() + .subscribe(this.actionIndicator.httpObserver( + 'Network History Cleaned Up', + 'Failed to Cleanup Network History.' + )) + } + + /** + * @private + * Trigger downloading of updates + * + * @param event - The mouse event + */ + downloadUpdates(event: Event) { + this.portapi.checkForUpdates() + .subscribe(this.actionIndicator.httpObserver( + 'Downloading Updates ...', + 'Failed to Check for Updates', + )) + } + + /** + * @private + * Trigger a shutdown of the portmaster-core service + */ + shutdown(_: Event) { + this.exitService.shutdownPortmaster(); + } + + /** + * @private + * Trigger a restart of the portmaster-core service. Requires + * that portmaster has been started with a service-wrapper. + * + * @param event The mouse event + */ + restart(event: Event) { + // prevent default and stop-propagation to avoid + // expanding the accordion body. + event.preventDefault(); + event.stopPropagation(); + + this.portapi.restartPortmaster() + .subscribe(this.actionIndicator.httpObserver( + 'Restarting ...', + 'Failed to Restart', + )) + } + + /** + * @private + * Opens the data-directory of the portmaster installation. + * Requires the application to run inside electron. + */ + async openDataDir(event: Event) { + const dir = await this.integration.getInstallDir() + await this.integration.openExternal(dir); + } + + openChangeLog() { + const url = "https://github.com/safing/portmaster/releases"; + this.integration.openExternal(url); + } + + showIntro() { + this.appComponent.showIntro() + } + + resetBroadcastState() { + this.portapi.resetBroadcastState() + .subscribe(this.actionIndicator.httpObserver( + 'Broadcast State Cleared', + 'Failed to Reset Broadcast State.', + )) + } + + copyDebugInfo(event: Event) { + // prevent default and stop-propagation to avoid + // expanding the accordion body. + event.preventDefault(); + event.stopPropagation(); + + this.debugAPI.getCoreDebugInfo() + .subscribe( + async info => { + await this.integration.writeToClipboard(info); + }, + err => { + console.error(err); + this.actionIndicator.error('Failed loading debug data', err); + } + ) + } +} diff --git a/desktop/angular/src/app/layout/side-dash/side-dash.html b/desktop/angular/src/app/layout/side-dash/side-dash.html new file mode 100644 index 00000000..81e8b15f --- /dev/null +++ b/desktop/angular/src/app/layout/side-dash/side-dash.html @@ -0,0 +1,10 @@ +
+ +
+ + + + + + + diff --git a/desktop/angular/src/app/layout/side-dash/side-dash.scss b/desktop/angular/src/app/layout/side-dash/side-dash.scss new file mode 100644 index 00000000..6862c275 --- /dev/null +++ b/desktop/angular/src/app/layout/side-dash/side-dash.scss @@ -0,0 +1,11 @@ +:host { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + overflow: hidden; + overflow-y: hidden; + width: 419px; + + @apply pt-4; +} diff --git a/desktop/angular/src/app/layout/side-dash/side-dash.ts b/desktop/angular/src/app/layout/side-dash/side-dash.ts new file mode 100644 index 00000000..c4836634 --- /dev/null +++ b/desktop/angular/src/app/layout/side-dash/side-dash.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'app-side-dash', + templateUrl: './side-dash.html', + styleUrls: ['./side-dash.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SideDashComponent { + /** Whether or not a SPN account login is required */ + spnLoginRequired = false; + +} diff --git a/desktop/angular/src/app/package-lock.json b/desktop/angular/src/app/package-lock.json new file mode 100644 index 00000000..4ef966ec --- /dev/null +++ b/desktop/angular/src/app/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "portmaster", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "portmaster", + "devDependencies": { + "@types/node": "^17.0.31" + } + }, + "node_modules/@types/node": { + "version": "17.0.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.31.tgz", + "integrity": "sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==", + "dev": true + } + }, + "dependencies": { + "@types/node": { + "version": "17.0.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.31.tgz", + "integrity": "sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==", + "dev": true + } + } +} diff --git a/desktop/angular/src/app/package.json b/desktop/angular/src/app/package.json new file mode 100644 index 00000000..119f6216 --- /dev/null +++ b/desktop/angular/src/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "portmaster", + "private": true, + "description_1": "This is a special package.json file that is not used by package managers.", + "description_2": "It is used to tell the tools and bundlers whether the code under this directory is free of code with non-local side-effect. Any code that does have non-local side-effects can't be well optimized (tree-shaken) and will result in unnecessary increased payload size.", + "description_3": "It should be safe to set this option to 'false' for new applications, but existing code bases could be broken when built with the production config if the application code does contain non-local side-effects that the application depends on.", + "description_4": "To learn more about this file see: https://angular.io/config/app-package-json.", + "sideEffects": false, + "devDependencies": { + "@types/node": "^17.0.31" + } +} diff --git a/desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.html b/desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.html new file mode 100644 index 00000000..32ab491c --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.html @@ -0,0 +1,13 @@ +
+ + + + + + + + + + + +
diff --git a/desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.ts b/desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.ts new file mode 100644 index 00000000..264a5615 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.ts @@ -0,0 +1,96 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AppProfile, BandwidthChartResult, ChartResult, Netquery } from '@safing/portmaster-api'; +import { repeat } from 'rxjs'; +import { CircularBarChartConfig, splitQueryResult } from 'src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component'; +import { DefaultBandwidthChartConfig } from 'src/app/shared/netquery/line-chart/line-chart'; + +interface CountryBarData { + series: 'country'; + value: number; + country: string; +} + +@Component({ + selector: 'app-app-insights', + templateUrl: './app-insights.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AppInsightsComponent implements OnInit { + private readonly netquery = inject(Netquery); + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + @Input() + profile!: AppProfile; + + connectionChart: ChartResult[] = []; + + bandwidthChart: BandwidthChartResult[] = []; + + bwChartConfig = DefaultBandwidthChartConfig; + + countryData: CountryBarData[] = []; + + readonly countryBarConfig: CircularBarChartConfig = { + stack: 'country', + seriesKey: 'series', + value: 'value', + ticks: 3, + colorAsClass: true, + series: { + 'count': { + color: 'text-green-300 text-opacity-50', + }, + }, + } + + ngOnInit() { + const key = `${this.profile.Source}/${this.profile.ID}` + + this.netquery.batch({ + countryData: { + select: [ + 'country', + { $count: { field: '*', as: 'count' } }, + ], + query: { + internal: { $eq: false }, + country: { $ne: '' } + }, + groupBy: ['country'] + } + }) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(result => { + this.countryData = splitQueryResult(result.countryData, ['count']) as CountryBarData[]; + console.log(this.countryData) + this.cdr.markForCheck(); + }) + + this.netquery.activeConnectionChart({ profile: key }) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(data => { + this.connectionChart = data; + this.cdr.markForCheck(); + }) + + this.netquery.bandwidthChart({ profile: key }, undefined, 60) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(data => { + this.bandwidthChart = data; + this.cdr.markForCheck(); + }) + + } + +} diff --git a/desktop/angular/src/app/pages/app-view/app-view.html b/desktop/angular/src/app/pages/app-view/app-view.html new file mode 100644 index 00000000..8003881c --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/app-view.html @@ -0,0 +1,425 @@ + + +
+ +
+ Apps + + + + + + {{ appProfile.Name }} +
+ + + +
+ + + +
+
+ +
+ +

+ + + {{appProfile!.Name}} +

+ + +
+
+ Path: + + {{ applicationDirectory }} + +
+
+ Binary: + + {{ binaryName }} + +
+
+ Active Connections: + {{stats?.countAliveConnections || 0}} +
+
+ Network History: + + As of {{ historyAvailableSince | date }} + Remove all {{ connectionsInHistory }} Connections + + + None + +
+
+ + +
+ + + + + + + + + + + + + + + + Edit App Profile + Export App Profile + Delete App Profile + + + +
+
+ + +
+
+

{{ stats!.size | prettyCount }}

+ Connections +
+ +
+

{{ (100 / stats!.size) * (stats!.size + - stats!.countAllowed) | number:'1.0-1' }}%

+ Blocked +
+ +
+

+ {{ stats.bytes_received | bytes }} +

+ + + Available in Plus + + + Received +
+ +
+

+ {{ stats.bytes_sent | bytes }} +

+ Sent +
+ +
+
+ +
+ + + +
+
+ + + + +
+ + +
+
+ + +
+ +
+ + + + + + + Get Help + + +
+ + + + View All + + + View Active + + +
+
+ +
+
+ App Specific Settings + +
+
+ + + + + + + +
+ + + + +

+ + {{ appProfile!.Name }} + + is fully using the global settings. +

+

+ Start creating exceptions for it now. +

+ +
+
+
+
+ + + +
+ +
+
+

+ + {{appProfile!.Name}} +

+

+ + {{appProfile!.PresentationPath}} +

+
+ +
+

+ + {{appProfile!.Created * 1000 | date:'medium'}} +

+

+ + {{appProfile!.LastEdited * 1000 | date:'medium'}} + N/A +

+
+ + +
+

+ + {{!!appProfile!.Internal ? 'yes' : 'no'}} +

+

+ + {{appProfile!.Source}} +

+

+ + {{appProfile!.ID}} +

+
+ +
+

+ + {{layeredProfile?.RevisionCounter}} +

+

+ + +

    +
  1. + {{layer}} +
  2. +
+ +

+
+
+
+ + +
+

+ + + + Description + +

+ + + +
+ + +
+

+ + + + Warning + +

+ + + + updated + {{ appProfile.WarningLastUpdated | timeAgo }} +
+ + +
+

+ + + + + Fingerprints + +

+ + This profile will be applied to processes that match one of the following + fingerprints: + +
+ + + + + + + + {{ fp.Type }} + + + where + {{ fp.Type === 'tag' ? (tagNames[fp.Key] || fp.Key) : fp.Key }} + + + {{ fp.Operation }} + {{ fp.Value }} +
+
+ + +
+

+ + + + Delete Profile + +

+ + You can completely delete this profile to get rid of any settings. The profile + will + be automatically re-created with default settings as soon as the application starts to use the + network. + + +
+ + +
+

+ + + + + + + Debugging + +

+ + When reporting issues with this app please make sure to include the + following + debug information: + + +
+
+
+ +
+ +
+
+
+
+ + diff --git a/desktop/angular/src/app/pages/app-view/app-view.scss b/desktop/angular/src/app/pages/app-view/app-view.scss new file mode 100644 index 00000000..977c3b72 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/app-view.scss @@ -0,0 +1,3 @@ +:host { + @apply flex flex-col h-screen max-h-screen; +} diff --git a/desktop/angular/src/app/pages/app-view/app-view.ts b/desktop/angular/src/app/pages/app-view/app-view.ts new file mode 100644 index 00000000..85a365a2 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/app-view.ts @@ -0,0 +1,641 @@ +import { + ChangeDetectorRef, + Component, + DestroyRef, + OnDestroy, + OnInit, + ViewChild, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AppProfile, + AppProfileService, + Condition, + ConfigService, + Database, + DebugAPI, + ExpertiseLevel, + FeatureID, + FlatConfigObject, + IProfileStats, + LayeredProfile, + Netquery, + PortapiService, + SPNService, + Setting, + flattenProfileConfig, + setAppSetting +} from '@safing/portmaster-api'; +import { SfngDialogService } from '@safing/ui'; +import { + BehaviorSubject, + Observable, + Subscription, + combineLatest, + interval, + of, + throwError, +} from 'rxjs'; +import { + catchError, + distinctUntilChanged, + map, + mergeMap, + startWith, + switchMap, +} from 'rxjs/operators'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; +import { SessionDataService } from 'src/app/services'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations'; +import { + ExportConfig, + ExportDialogComponent, +} from 'src/app/shared/config/export-dialog/export-dialog.component'; +import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting'; +import { ExpertiseService } from 'src/app/shared/expertise'; +import { SfngNetqueryViewer } from 'src/app/shared/netquery'; +import { EditProfileDialog } from './../../shared/edit-profile-dialog/edit-profile-dialog'; + +@Component({ + templateUrl: './app-view.html', + styleUrls: ['../page.scss', './app-view.scss'], + animations: [fadeOutAnimation, fadeInAnimation], +}) +export class AppViewComponent implements OnInit, OnDestroy { + private readonly integration = inject(INTEGRATION_SERVICE); + + @ViewChild(SfngNetqueryViewer) + netqueryViewer?: SfngNetqueryViewer; + + destroyRef = inject(DestroyRef); + spn = inject(SPNService); + + canUseHistory = false; + canViewBW = false; + canUseSPN = false; + + /** subscription to our update-process observable */ + private subscription = Subscription.EMPTY; + + /** + * @private + * historyAvailableSince holds the date of the oldes connection + * in the history database for this app. + */ + historyAvailableSince: Date | null = null; + + /** + * @private + * connectionsInHistory holds the total amount of connections + * in the history database for this app + */ + connectionsInHistory = 0; + + /** + * @private + * The current AppProfile we are showing. + */ + appProfile: AppProfile | null = null; + + /** + * @private + * Whether or not the overview componet should be rendered. + */ + get showOverview() { + return this.appProfile == null && !this._loading; + } + + /** + * @private + * The currently displayed list of settings + */ + settings: Setting[] = []; + + profileSettings: Setting[] = []; + + /** + * @private + * All available settings. + */ + allSettings: Setting[] = []; + + /** + * @private + * The current search term displayed in the search-input. + */ + searchTerm = ''; + + /** + * @private + * The key of the setting to highligh, if any ... + */ + highlightSettingKey: string | null = null; + + /** + * @private + * Emits whenever the currently used settings "view" changes. + */ + viewSettingChange = new BehaviorSubject<'all' | 'active'>('all'); + + /** + * @private + * The path of the application binary + */ + applicationDirectory = ''; + + /** + * @private + * The name of the binary + */ + binaryName = ''; + + /** + * @private + * Whether or not the profile warning message should be displayed + */ + displayWarning = false; + + /** + * @private + * The current profile statistics + */ + stats: IProfileStats | null = null; + + /** + * @private + * The internal, layered profile if the app is active + */ + layeredProfile: LayeredProfile | null = null; + + /** Used to track whether we are already initialized */ + private _loading = true; + + /** + * @private + * + * Defines what "view" we are currently in + */ + get viewSetting(): 'all' | 'active' { + return this.viewSettingChange.getValue(); + } + + /** A lookup map from tag ID to tag Name */ + tagNames: { + [tagID: string]: string; + } = {}; + + collapseHeader = false; + + constructor( + public sessionDataService: SessionDataService, + private profileService: AppProfileService, + private route: ActivatedRoute, + private netquery: Netquery, + private cdr: ChangeDetectorRef, + private configService: ConfigService, + private router: Router, + private actionIndicator: ActionIndicatorService, + private dialog: SfngDialogService, + private debugAPI: DebugAPI, + private expertiseService: ExpertiseService, + private portapi: PortapiService + ) { } + + /** + * @private + * Used to save a change in the app settings. Emitted by the config-view + * component + * + * @param event The emitted save-settings-event. + */ + saveSetting(event: SaveSettingEvent) { + // Guard against invalid usage and abort if there's not appProfile + // to save. + if (!this.appProfile) { + return; + } + + if (!this.appProfile!.Config) { + this.appProfile.Config = {} + } + + // If the value has been "reset to global value" we need to + // set the value to "undefined". + if (event.isDefault) { + setAppSetting(this.appProfile!.Config, event.key, undefined); + } else { + setAppSetting(this.appProfile!.Config, event.key, event.value); + } + + // Actually safe the profile + this.profileService.saveProfile(this.appProfile!).subscribe({ + next: () => { + if (!!event.accepted) { + event.accepted(); + } + }, + error: (err) => { + // if there's a callback function for errors call it. + if (!!event.rejected) { + event.rejected(err); + } + + console.error(err); + this.actionIndicator.error('Failed to save setting', err); + }, + }); + } + + exportProfile() { + if (!this.appProfile) { + return; + } + + this.portapi + .exportProfile(`${this.appProfile.Source}/${this.appProfile.ID}`) + .subscribe((exportBlob) => { + const exportConfig: ExportConfig = { + type: 'profile', + content: exportBlob, + }; + + this.dialog.create(ExportDialogComponent, { + data: exportConfig, + autoclose: false, + backdrop: true, + }); + }); + } + + editProfile() { + if (!this.appProfile) { + return; + } + + this.dialog + .create(EditProfileDialog, { + backdrop: true, + autoclose: false, + data: `${this.appProfile.Source}/${this.appProfile.ID}`, + }) + .onAction('deleted', () => { + // navigate to the app overview if it has been deleted. + this.router.navigate(['/app/']); + }); + } + + cleanProfileHistory() { + if (!this.appProfile) { + return; + } + + const observer = this.actionIndicator.httpObserver( + 'History successfully removed', + 'Failed to remove history' + ); + + this.netquery + .cleanProfileHistory(this.appProfile.Source + '/' + this.appProfile.ID) + .subscribe({ + next: (res) => { + observer.next!(res); + this.historyAvailableSince = null; + this.connectionsInHistory = 0; + this.cdr.markForCheck(); + }, + error: (err) => { + observer.error!(err); + }, + }); + } + + ngOnInit() { + this.profileService.tagDescriptions().subscribe((tags) => { + tags.forEach((t) => { + this.tagNames[t.ID] = t.Name; + this.cdr.markForCheck(); + }); + }); + + // watch the route parameters and start watching the referenced + // application profile, it's layer profile and polling the stats. + const profileStream: Observable< + [AppProfile, LayeredProfile | null, IProfileStats | null] | null + > = this.route.paramMap.pipe( + switchMap((params) => { + // Get the profile source and id. If one is unset (null) + // than return a"null" emit-once stream. + const source = params.get('source'); + const id = params.get('id'); + if (source === null || id === null) { + this._loading = false; + return of(null); + } + this._loading = true; + + this.historyAvailableSince = null; + this.connectionsInHistory = 0; + this.appProfile = null; + this.stats = null; + + // Start watching the application profile. + // switchMap will unsubscribe automatically if + // we start watching a different profile. + return this.profileService.getAppProfile(source, id).pipe( + catchError((err) => { + if (typeof err === 'string') { + err = new Error(err); + } + + this.router.navigate(['/app/overview'], { + onSameUrlNavigation: 'reload', + }); + + this.actionIndicator.error( + 'Failed To Get Profile', + this.actionIndicator.getErrorMessgae(err) + ); + + return throwError(() => err); + }), + mergeMap(() => { + return combineLatest([ + this.profileService.watchAppProfile(source, id), + this.profileService + .watchLayeredProfile(source, id) + .pipe(startWith(null)), + interval(10000).pipe( + startWith(-1), + mergeMap(() => + this.netquery + .getProfileStats({ + profile: `${source}/${id}`, + }) + .pipe(map((result) => result?.[0])) + ), + startWith(null) + ), + ]); + }) + ); + }) + ); + + // used to track changes to the object identity of the global configuration + let prevousGlobal: FlatConfigObject = {}; + + this.subscription = combineLatest([ + profileStream, // emits the current app profile everytime it changes + this.route.queryParamMap, // for changes to the settings= query parameter + this.profileService.globalConfig(), // for changes to ghe global profile + this.configService.query(''), // get ALL settings (once, only the defintion is of intereset) + this.viewSettingChange.pipe( + // watch the current "settings-view" setting, but only if it changes + distinctUntilChanged() + ), + ]).subscribe( + async ([profile, queryMap, global, allSettings, viewSetting]) => { + const previousProfile = this.appProfile; + + if (!!profile) { + const key = profile![0].Source + '/' + profile![0].ID; + + const query: Condition = { + profile: key, + }; + + // ignore internal connections if the user is not in developer mode. + if (this.expertiseService.currentLevel !== ExpertiseLevel.Developer) { + query.internal = { + $eq: false, + }; + } + + this.netquery + .query( + { + select: [ + { + $min: { + field: 'started', + as: 'first_connection', + }, + }, + { + $count: { + field: '*', + as: 'totalCount', + }, + }, + ], + groupBy: ['profile'], + query: { + profile: `${profile[0].Source}/${profile[0].ID}`, + }, + databases: [Database.History], + }, + 'app-view-get-first-connection' + ) + .subscribe((result) => { + if (result.length > 0) { + this.historyAvailableSince = new Date( + result[0].first_connection! + ); + this.connectionsInHistory = result[0].totalCount; + } else { + this.historyAvailableSince = null; + this.connectionsInHistory = 0; + } + + this.cdr.markForCheck(); + }); + + this.appProfile = profile[0] || null; + this.layeredProfile = profile[1] || null; + this.stats = profile[2] || null; + } else { + this.appProfile = null; + this.layeredProfile = null; + this.stats = null; + } + + this.displayWarning = false; + + if (this.appProfile?.WarningLastUpdated) { + const now = new Date().getTime(); + const diff = + now - new Date(this.appProfile.WarningLastUpdated).getTime(); + this.displayWarning = diff < 1000 * 60 * 60 * 24 * 7; + } + + if (!!this.netqueryViewer && this._loading) { + this.netqueryViewer.performSearch(); + } + + this._loading = false; + + if (!!this.appProfile?.PresentationPath) { + let parts: string[] = []; + let sep = '/'; + if (this.appProfile.PresentationPath[0] === '/') { + // linux, darwin, bsd ... + sep = '/'; + } else { + // windows ... + sep = '\\'; + } + parts = this.appProfile.PresentationPath.split(sep); + + this.binaryName = parts.pop()!; + this.applicationDirectory = parts.join(sep); + } else { + this.applicationDirectory = ''; + this.binaryName = ''; + } + + this.highlightSettingKey = queryMap.get('setting'); + let profileConfig: FlatConfigObject = {}; + + // if we have a profile flatten it's configuration map to something + // more useful. + if (!!this.appProfile) { + profileConfig = flattenProfileConfig(this.appProfile.Config); + } + + // if we should highlight a setting make sure to switch the + // viewSetting to all if it's the "global" default (that is, no + // value is set). Otherwise the setting won't render and we cannot + // highlight it. + // We need to keep this even though we default to "all" now since + // the following might happen: + // - user already navigated to an app-page and selected "View Active". + // - a notification comes in that has a "show setting" action + // - the user clicks the action button and the setting should be displayed + // - since the requested setting has not been changed it is not available + // in "View Active" so we need to switch back to "View All". Otherwise + // the action button would fail and the user would not notice something + // changing. + // + if (!!this.highlightSettingKey) { + if (profileConfig[this.highlightSettingKey] === undefined) { + this.viewSettingChange.next('all'); + } + } + + // check if we got new values for the profile or the settings. In both cases, we need to update the + // profile settings displayed as there might be new values to show. + const profileChanged = previousProfile !== this.appProfile; + const settingsChanged = allSettings !== this.allSettings; + const globalChanged = global !== prevousGlobal; + + const settingsNeedUpdate = + profileChanged || settingsChanged || globalChanged; + + // save the current global config object so we can compare for identity changes + // the next time we're executed + prevousGlobal = global; + + if (!!this.appProfile && settingsNeedUpdate) { + // filter the settings and remove all settings that are not + // profile specific (i.e. not part of the global config). Also + // update the current settings value (from the app profile) and + // the default value (from the global profile). + this.profileSettings = allSettings.map((setting) => { + setting.Value = profileConfig[setting.Key]; + setting.GlobalDefault = global[setting.Key]; + + return setting; + }); + + this.settings = this.profileSettings.filter((setting) => { + if (!(setting.Key in global)) { + return false; + } + + const isModified = setting.Value !== undefined; + if (this.viewSetting === 'all') { + return true; + } + return isModified; + }); + + this.allSettings = allSettings; + } + + this.cdr.markForCheck(); + } + ); + + this.spn.profile$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (profile) => { + this.canUseHistory = + profile?.current_plan?.feature_ids?.includes(FeatureID.History) || + false; + this.canViewBW = + profile?.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || + false; + this.canUseSPN = + profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) || false; + }, + }); + } + + /** + * @private + * Retrieves debug information from the current + * profile and copies it to the clipboard + */ + copyDebugInfo() { + if (!this.appProfile) { + return; + } + + this.debugAPI + .getProfileDebugInfo(this.appProfile.Source, this.appProfile.ID) + .subscribe(async (data) => { + console.log(data); + // Copy to clip-board if supported + await this.integration.writeToClipboard(data); + this.actionIndicator.success('Copied to Clipboard'); + }); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + /** + * @private + * Delete the current profile. Requires a two-step confirmation. + */ + deleteProfile() { + if (!this.appProfile) { + return; + } + + this.dialog + .confirm({ + canCancel: true, + caption: 'Caution', + header: 'Deleting Profile ' + this.appProfile.Name, + message: + 'Do you really want to delete this profile? All settings will be lost.', + buttons: [ + { id: '', text: 'Cancel', class: 'outline' }, + { id: 'delete', class: 'danger', text: 'Yes, delete it' }, + ], + }) + .onAction('delete', () => { + this.profileService.deleteProfile(this.appProfile!).subscribe(() => { + this.router.navigate(['/app/overview']); + this.actionIndicator.success( + 'Profile Deleted', + 'Successfully deleted profile ' + this.appProfile?.Name + ); + }); + }); + } +} diff --git a/desktop/angular/src/app/pages/app-view/index.ts b/desktop/angular/src/app/pages/app-view/index.ts new file mode 100644 index 00000000..54220c42 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/index.ts @@ -0,0 +1,3 @@ +export { AppViewComponent } from './app-view'; +export { AppOverviewComponent } from './overview'; +export { QuickSettingInternetButtonComponent } from './qs-internet'; diff --git a/desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.html b/desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.html new file mode 100644 index 00000000..ef6d1829 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.html @@ -0,0 +1,36 @@ +
+

+ Merge Profiles +

+ + +
+ + + Please select the primary profile. All other selected profiles will be merged into the primary profile by copying metadata, fingerprints and icons into a new profile. + Only the settings of the primary profile will be kept. + + +
+ + + + + + {{ p.Name }} + + + +
+ +
+ + +
+ +
+ + +
diff --git a/desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.ts b/desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.ts new file mode 100644 index 00000000..d609afb1 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.ts @@ -0,0 +1,62 @@ +import { AppProfile } from './../../../../../dist-lib/safing/portmaster-api/lib/app-profile.types.d'; +import { ChangeDetectionStrategy, Component, OnInit, TrackByFunction, inject } from "@angular/core"; +import { Router } from '@angular/router'; +import { PortapiService } from '@safing/portmaster-api'; +import { SFNG_DIALOG_REF, SfngDialogRef } from "@safing/ui"; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; + +@Component({ + templateUrl: './merge-profile-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + @apply flex flex-col gap-2 justify-start h-96 w-96; + } + ` + ] +}) +export class MergeProfileDialogComponent implements OnInit { + readonly dialogRef: SfngDialogRef = inject(SFNG_DIALOG_REF); + private readonly portapi = inject(PortapiService); + private readonly router = inject(Router); + private readonly uai = inject(ActionIndicatorService); + + get profiles(): AppProfile[] { + return this.dialogRef.data; + } + + primary: AppProfile | null = null; + newName = ''; + + trackProfile: TrackByFunction = (_, p) => `${p.Source}/${p.ID}` + + ngOnInit(): void { + (() => { }); + } + + mergeProfiles() { + if (!this.primary) { + return + } + + this.portapi.mergeProfiles( + this.newName, + `${this.primary.Source}/${this.primary.ID}`, + this.profiles + .filter(p => p !== this.primary) + .map(p => `${p.Source}/${p.ID}`) + ) + .subscribe({ + next: newID => { + this.router.navigate(['/app/' + newID]) + this.uai.success('Profiles Merged Successfully', 'All selected profiles have been merged') + + this.dialogRef.close() + }, + error: err => { + this.uai.error('Failed To Merge Profiles', this.uai.getErrorMessgae(err)) + } + }) + } +} diff --git a/desktop/angular/src/app/pages/app-view/overview.html b/desktop/angular/src/app/pages/app-view/overview.html new file mode 100644 index 00000000..defb9c85 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/overview.html @@ -0,0 +1,193 @@ +
+ + +
+ +
+

+ All Apps + +

+
+ + + Create profile + Import Profile + Merge or Delete profiles + + +
+ +
+ Manage + + + + +
+
+ + + + Merge Profiles + Delete Profiles + Cancel + + + +
+ {{ selectedProfileCount}} selected + + + + +
+
+
+
+
+ +
+ +
+

Active

+
+ +
+ + +
+

Recently Edited

+
+ +
+ + +
+

All

+
+ +
+ + + +
+ + + + + + + + + + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ No applications match your search term. +
diff --git a/desktop/angular/src/app/pages/app-view/overview.scss b/desktop/angular/src/app/pages/app-view/overview.scss new file mode 100644 index 00000000..87462ccb --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/overview.scss @@ -0,0 +1,54 @@ +:host { + justify-content: flex-start; +} + +.header-title { + display: flex; + width: 100%; + margin-bottom: 0.5rem; + align-items: center; + height: 3rem; + flex-shrink: 0; + + h1 { + flex-grow: unset; + } + + fa-icon[icon*="question-circle"] { + margin-left: 0.35rem; + } +} + +.scrollable { + width: auto; + flex-grow: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + + +.scrollable-header { + + @apply bg-background; + @apply pt-4; + @apply pb-1; + width: 100%; + position: sticky; + top: 0px; + display: flex; + + grid-column: 1 / -1; + + fa-icon[icon*="question-circle"] { + margin-left: 0.35rem; + } +} + + +.card-header { + // Card headers have top-margin by default. + // Since we're using a grid-gap anyway we need + // to clear the margin. + @apply mt-0; +} diff --git a/desktop/angular/src/app/pages/app-view/overview.ts b/desktop/angular/src/app/pages/app-view/overview.ts new file mode 100644 index 00000000..3c995621 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/overview.ts @@ -0,0 +1,305 @@ +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + TrackByFunction, +} from '@angular/core'; +import { + AppProfile, + AppProfileService, + Netquery, + trackById, +} from '@safing/portmaster-api'; +import { SfngDialogService } from '@safing/ui'; +import { BehaviorSubject, Subscription, combineLatest, forkJoin } from 'rxjs'; +import { debounceTime, filter, startWith } from 'rxjs/operators'; +import { + fadeInAnimation, + fadeInListAnimation, + moveInOutListAnimation, +} from 'src/app/shared/animations'; +import { FuzzySearchService } from 'src/app/shared/fuzzySearch'; +import { EditProfileDialog } from './../../shared/edit-profile-dialog/edit-profile-dialog'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { MergeProfileDialogComponent } from './merge-profile-dialog/merge-profile-dialog.component'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { Router } from '@angular/router'; +import { + ImportConfig, + ImportDialogComponent, +} from 'src/app/shared/config/import-dialog/import-dialog.component'; + +interface LocalAppProfile extends AppProfile { + hasConfigChanges: boolean; + selected: boolean; +} + +@Component({ + selector: 'app-settings-overview', + templateUrl: './overview.html', + styleUrls: ['../page.scss', './overview.scss'], + animations: [fadeInAnimation, fadeInListAnimation, moveInOutListAnimation], +}) +export class AppOverviewComponent implements OnInit, OnDestroy { + private subscription = Subscription.EMPTY; + + /** Whether or not we are currently loading */ + loading = true; + + /** All application profiles that are actually running */ + runningProfiles: LocalAppProfile[] = []; + + /** All application profiles that have been edited recently */ + recentlyEdited: LocalAppProfile[] = []; + + /** All application profiles */ + profiles: LocalAppProfile[] = []; + + /** The current search term */ + searchTerm: string = ''; + + /** total number of profiles */ + total: number = 0; + + /** Whether or not we are in profile-selection mode */ + set selectMode(v: any) { + this._selectMode = coerceBooleanProperty(v); + + // reset all previous profile selections + if (!this._selectMode) { + this.profiles.forEach((profile) => (profile.selected = false)); + } + } + get selectMode() { + return this._selectMode; + } + private _selectMode = false; + + get selectedProfileCount() { + return this.profiles.reduce( + (sum, profile) => (profile.selected ? sum + 1 : sum), + 0 + ); + } + + /** Observable emitting the search term */ + private onSearch = new BehaviorSubject(''); + + /** TrackBy function for the profiles. */ + trackProfile: TrackByFunction = trackById; + + constructor( + private profileService: AppProfileService, + private changeDetector: ChangeDetectorRef, + private searchService: FuzzySearchService, + private netquery: Netquery, + private dialog: SfngDialogService, + private actionIndicator: ActionIndicatorService, + private router: Router + ) { } + + handleProfileClick(profile: LocalAppProfile, event: MouseEvent) { + if (event.shiftKey) { + // stay on the same page as clicking the app actually triggers + // a navigation before this handler is executed. + this.router.navigate(['/app/overview']); + + this.selectMode = true; + + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); + } + + if (this.selectMode) { + profile.selected = !profile.selected; + } + + if (event.shiftKey && this.selectedProfileCount === 0) { + this.selectMode = false; + } + } + + importProfile() { + const importConfig: ImportConfig = { + type: 'profile', + key: '', + }; + + this.dialog.create(ImportDialogComponent, { + data: importConfig, + autoclose: false, + backdrop: 'light', + }); + } + + openMergeDialog() { + this.dialog.create(MergeProfileDialogComponent, { + autoclose: true, + backdrop: 'light', + data: this.profiles.filter((p) => p.selected), + }); + + this.selectMode = false; + } + + deleteSelectedProfiles() { + this.dialog + .confirm({ + header: 'Confirm Profile Deletion', + message: `Are you sure you want to delete all ${this.selectedProfileCount} selected profiles?`, + caption: 'Attention', + buttons: [ + { + id: 'no', + text: 'Cancel', + class: 'outline', + }, + { + id: 'yes', + text: 'Delete', + class: 'danger', + }, + ], + }) + .onAction('yes', () => { + forkJoin( + this.profiles + .filter((profile) => profile.selected) + .map((p) => this.profileService.deleteProfile(p)) + ).subscribe({ + next: () => { + this.actionIndicator.success( + 'Selected Profiles Delete', + 'All selected profiles have been deleted' + ); + }, + error: (err) => { + this.actionIndicator.error( + 'Failed To Delete Profiles', + `An error occured while deleting some profiles: ${this.actionIndicator.getErrorMessgae( + err + )}` + ); + }, + }); + }) + .onClose.subscribe(() => (this.selectMode = false)); + } + + ngOnInit() { + // watch all profiles and re-emit (debounced) when the user + // enters or chanages the search-text. + this.subscription = combineLatest([ + this.profileService.watchProfiles(), + this.onSearch.pipe(debounceTime(100), startWith('')), + this.netquery.getActiveProfileIDs().pipe(startWith([] as string[])), + ]).subscribe(([profiles, searchTerm, activeProfiles]) => { + this.loading = false; + + // find all profiles that match the search term. For searchTerm="" thsi + // will return all profiles. + const filtered = this.searchService.searchList(profiles, searchTerm, { + ignoreLocation: true, + ignoreFieldNorm: true, + threshold: 0.1, + minMatchCharLength: 3, + keys: ['Name', 'PresentationPath'], + }); + + // create a lookup map of all profiles we already loaded so we don't loose + // selection state when a profile has been updated. + const oldProfiles = new Map( + this.profiles.map((profile) => [ + `${profile.Source}/${profile.ID}`, + profile, + ]) + ); + + // Prepare new, empty lists for our groups + this.profiles = []; + this.runningProfiles = []; + this.recentlyEdited = []; + + // calcualte the threshold for "recently-used" (1 week). + const recentlyUsedThreshold = + new Date().valueOf() / 1000 - 60 * 60 * 24 * 7; + + // flatten the filtered profiles, sort them by name and group them into + // our "app-groups" (active, recentlyUsed, others) + this.total = filtered.length; + filtered + .map((item) => item.item) + .sort((a, b) => { + const aName = a.Name.toLocaleLowerCase(); + const bName = b.Name.toLocaleLowerCase(); + + if (aName > bName) { + return 1; + } + + if (aName < bName) { + return -1; + } + + return 0; + }) + .forEach((profile) => { + const local: LocalAppProfile = { + ...profile, + hasConfigChanges: + profile.LastEdited > 0 && Object.keys(profile.Config || {}).length > 0, + selected: + oldProfiles.get(`${profile.Source}/${profile.ID}`)?.selected || + false, + }; + + if (activeProfiles.includes(profile.Source + '/' + profile.ID)) { + this.runningProfiles.push(local); + } else if (profile.LastEdited >= recentlyUsedThreshold) { + this.recentlyEdited.push(local); + } + + // we always add the profile to "All Apps" + this.profiles.push(local); + }); + + this.changeDetector.markForCheck(); + }); + } + + /** + * @private + * + * Used as an ngModelChange callback on the search-input. + * + * @param term The search term entered by the user + */ + searchApps(term: string) { + this.searchTerm = term; + this.onSearch.next(term); + } + + /** + * @private + * + * Opens the create profile dialog + */ + createProfile() { + const ref = this.dialog.create(EditProfileDialog, { + backdrop: true, + autoclose: false, + }); + + ref.onClose.pipe(filter((action) => action === 'saved')).subscribe(() => { + // reset the search and reload to make sure the new + // profile shows up + this.searchApps(''); + }); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.html b/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.html new file mode 100644 index 00000000..77c34199 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.html @@ -0,0 +1,12 @@ +
+ + Keep History + + + + Get Plus + + + +
diff --git a/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.scss b/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.ts b/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.ts new file mode 100644 index 00000000..24e6296a --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.ts @@ -0,0 +1,67 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + BoolSetting, + FeatureID, + SPNService, + Setting, + getActualValue, +} from '@safing/portmaster-api'; +import { BehaviorSubject, Observable, map } from 'rxjs'; +import { share } from 'rxjs/operators'; +import { SaveSettingEvent } from 'src/app/shared/config'; + +@Component({ + selector: 'app-qs-history', + templateUrl: './qs-history.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QsHistoryComponent implements OnChanges { + currentValue = false; + historyFeatureAllowed: Observable = inject(SPNService).profile$.pipe( + takeUntilDestroyed(), + map((profile) => { + return ( + profile?.current_plan?.feature_ids?.includes(FeatureID.History) || false + ); + }), + share({ connector: () => new BehaviorSubject(false) }) + ); + + @Input() + canUse: boolean = true; + + @Input() + settings: Setting[] = []; + + @Output() + save = new EventEmitter>(); + + ngOnChanges(changes: SimpleChanges): void { + if ('settings' in changes) { + const historySetting = this.settings.find( + (s) => s.Key === 'history/enable' + ) as BoolSetting | undefined; + if (historySetting) { + this.currentValue = getActualValue(historySetting); + } + } + } + + updateHistoryEnabled(enabled: boolean) { + this.save.next({ + isDefault: false, + key: 'history/enable', + value: enabled, + }); + } +} diff --git a/desktop/angular/src/app/pages/app-view/qs-internet/index.ts b/desktop/angular/src/app/pages/app-view/qs-internet/index.ts new file mode 100644 index 00000000..7fd0d0c4 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-internet/index.ts @@ -0,0 +1 @@ +export * from './qs-internet'; diff --git a/desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.html b/desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.html new file mode 100644 index 00000000..cf87f254 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.html @@ -0,0 +1,30 @@ +
+ + Block Connections + + + + + + + + Prompting + +
+ + + The following enabled settings may interfere: +
    + +
  • + {{ setting.Name }} +
  • +
    +
+
diff --git a/desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.ts b/desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.ts new file mode 100644 index 00000000..7b606660 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.ts @@ -0,0 +1,79 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; +import { Setting, StringSetting, getActualValue } from "@safing/portmaster-api"; +import { SaveSettingEvent } from "src/app/shared/config/generic-setting/generic-setting"; + +const interferingSettings = { + 'permit': [ + 'filter/blockInternet', + 'filter/blockLAN', + 'filter/blockLocal', + 'filter/blockP2P', + 'filter/blockInbound', + 'filter/endpoints', + ], + 'block': [ + 'filter/endpoints', + ], +} + +@Component({ + selector: 'app-qs-internet', + templateUrl: './qs-internet.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class QuickSettingInternetButtonComponent implements OnChanges { + @Input() + settings: Setting[] = []; + + @Output() + save = new EventEmitter(); + + currentValue = '' + + interferingSettings: Setting[] = []; + + ngOnChanges(changes: SimpleChanges): void { + if ('settings' in changes) { + this.currentValue = ''; + const defaultActionSetting = this.settings.find(s => s.Key == 'filter/defaultAction') as (StringSetting | undefined); + if (!!defaultActionSetting) { + this.currentValue = getActualValue(defaultActionSetting); + this.updateInterfering(); + } + } + } + + updateUseInternet(blocked: boolean) { + const newValue = blocked ? 'block' : 'permit'; + this.save.next({ + isDefault: false, + key: 'filter/defaultAction', + value: newValue, + }) + } + + private updateInterfering() { + this.interferingSettings = []; + if (this.currentValue !== 'permit' && this.currentValue !== 'block') { + return; + } + + // create a lookup map for setting key to setting + const lm = new Map(); + this.settings.forEach(s => lm.set(s.Key, s)) + + this.interferingSettings = interferingSettings[this.currentValue] + .map(key => lm.get(key)) + .filter(setting => { + if (!setting) { + return false; + } + const value = getActualValue(setting); + if (Array.isArray(value)) { + return value.length > 0; + } + + return !!value; + }) as Setting[]; + } +} diff --git a/desktop/angular/src/app/pages/app-view/qs-select-exit/index.ts b/desktop/angular/src/app/pages/app-view/qs-select-exit/index.ts new file mode 100644 index 00000000..56c7267e --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-select-exit/index.ts @@ -0,0 +1 @@ +export * from './qs-select-exit'; diff --git a/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.html b/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.html new file mode 100644 index 00000000..66f9fa13 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.html @@ -0,0 +1,39 @@ +
+ + SPN Exit + + + + Get Pro + + + + + Automatic + + + + + + + + + + {{ option.Name }} + + + + + + + + + + Disabled + + +
diff --git a/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.scss b/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.scss new file mode 100644 index 00000000..e69de29b diff --git a/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts b/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts new file mode 100644 index 00000000..698607b6 --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts @@ -0,0 +1,128 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + BoolSetting, + StringArraySetting, + CountrySelectionQuickSetting, + ConfigService, + Setting, + getActualValue, +} from '@safing/portmaster-api'; +import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting'; + +@Component({ + selector: 'app-qs-select-exit', + templateUrl: './qs-select-exit.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickSettingSelectExitButtonComponent + implements OnInit, OnChanges +{ + private destroyRef = inject(DestroyRef); + + @Input() + canUse: boolean = true; + + @Input() + settings: Setting[] = []; + + @Output() + save = new EventEmitter(); + + spnEnabled: boolean | null = null; + exitRuleSetting: StringArraySetting | null = null; + + selectedExitRules: string | undefined = undefined; + availableExitRules: CountrySelectionQuickSetting[] | null = null; + + constructor( + private configService: ConfigService, + private cdr: ChangeDetectorRef + ) {} + + updateExitRules(newExitRules: string) { + this.selectedExitRules = newExitRules; + + let newConfigValue: string[] = []; + if (!!newExitRules) { + newConfigValue = newExitRules.split(','); + } + + this.save.next({ + isDefault: false, + key: 'spn/exitHubPolicy', + value: newConfigValue, + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('settings' in changes) { + this.exitRuleSetting = null; + this.selectedExitRules = undefined; + + const exitRuleSetting = this.settings.find( + (s) => s.Key == 'spn/exitHubPolicy' + ) as StringArraySetting | undefined; + if (exitRuleSetting) { + this.exitRuleSetting = exitRuleSetting; + this.updateOptions(); + } + } + } + + ngOnInit() { + this.configService + .watch('spn/enable') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.spnEnabled = value; + this.updateOptions(); + }); + } + + private updateOptions() { + if (!this.exitRuleSetting) { + this.selectedExitRules = undefined; + this.availableExitRules = null; + return; + } + + if (!!this.exitRuleSetting.Value && this.exitRuleSetting.Value.length > 0) { + this.selectedExitRules = this.exitRuleSetting.Value.join(','); + } + this.availableExitRules = this.getQuickSettings(); + + this.cdr.markForCheck(); + } + + private getQuickSettings(): CountrySelectionQuickSetting[] { + if (!this.exitRuleSetting) { + return []; + } + + let val = this.exitRuleSetting.Annotations[ + 'safing/portbase:ui:quick-setting' + ] as CountrySelectionQuickSetting[]; + if (val === undefined) { + return []; + } + + if (!Array.isArray(val)) { + return []; + } + + return val; + } +} diff --git a/desktop/angular/src/app/pages/app-view/qs-use-spn/index.ts b/desktop/angular/src/app/pages/app-view/qs-use-spn/index.ts new file mode 100644 index 00000000..aba9748a --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-use-spn/index.ts @@ -0,0 +1 @@ +export * from './qs-use-spn'; diff --git a/desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.html b/desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.html new file mode 100644 index 00000000..58ceb09d --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.html @@ -0,0 +1,42 @@ +
+ + Use SPN + + + + Get Pro + + + + + + + Disabled + + + + + + + + +
+ + + The following enabled settings may interfere: +
    + +
  • + {{ setting.Name }} +
  • +
    +
+
diff --git a/desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.ts b/desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.ts new file mode 100644 index 00000000..f4e5bb2e --- /dev/null +++ b/desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.ts @@ -0,0 +1,97 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BoolSetting, ConfigService, Setting, getActualValue } from "@safing/portmaster-api"; +import { SaveSettingEvent } from "src/app/shared/config/generic-setting/generic-setting"; + +const interferingSettingsWhenOn = [ + 'spn/usagePolicy' +] + +@Component({ + selector: 'app-qs-use-spn', + templateUrl: './qs-use-spn.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class QuickSettingUseSPNButtonComponent implements OnInit, OnChanges { + private destroyRef = inject(DestroyRef); + + @Input() + canUse: boolean = true; + + @Input() + settings: Setting[] = []; + + @Output() + save = new EventEmitter(); + + currentValue = false + + interferingSettings: Setting[] = []; + + /* Whether or not the SPN is currently disabled. null means we don't know yet ... */ + spnDisabled: boolean | null = null; + + constructor( + private configService: ConfigService, + private cdr: ChangeDetectorRef + ) { } + + ngOnChanges(changes: SimpleChanges): void { + if ('settings' in changes) { + this.currentValue = false; + + const useSpnSetting = this.settings.find(s => s.Key === 'spn/use') as (BoolSetting | undefined); + if (!!useSpnSetting) { + this.currentValue = getActualValue(useSpnSetting); + this.updateInterfering(); + } + } + } + + updateUseSpn(allowed: boolean) { + this.save.next({ + isDefault: false, + key: 'spn/use', + value: allowed, + }) + } + + ngOnInit() { + this.configService.watch('spn/enable') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(value => { + this.spnDisabled = !value; + this.cdr.markForCheck(); + this.updateInterfering(); + }) + } + + private updateInterfering() { + this.interferingSettings = []; + + // only "enabled" state has interfering settings + // only show if we already know if the SPN module is enabled + if (!this.currentValue || this.spnDisabled !== false) { + return + } + + // create a lookup map for setting key to setting + const lm = new Map(); + this.settings.forEach(s => lm.set(s.Key, s)) + + + this.interferingSettings = interferingSettingsWhenOn + .map(key => lm.get(key)) + .filter(setting => { + if (!setting) { + return false; + } + const value = getActualValue(setting); + if (Array.isArray(value)) { + return value.length > 0; + } + + return !!value; + }) as Setting[]; + } +} diff --git a/desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.html b/desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.html new file mode 100644 index 00000000..65fd80cf --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.html @@ -0,0 +1,14 @@ + + + diff --git a/desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.ts b/desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.ts new file mode 100644 index 00000000..35d2668a --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.ts @@ -0,0 +1,30 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; + +@Component({ + selector: 'app-dashboard-widget', + templateUrl: './dashboard-widget.component.html', + styles: [ + ` + :host { + @apply bg-gray-200 p-4 self-stretch rounded-md flex flex-col gap-2; + } + + label { + @apply text-xs uppercase text-secondary font-light flex flex-row items-center gap-2 pb-2; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DashboardWidgetComponent { + @Input() + set beta(v: any) { + this._beta = coerceBooleanProperty(v) + } + get beta() { return this._beta } + private _beta = false; + + @Input() + label: string = ''; +} diff --git a/desktop/angular/src/app/pages/dashboard/dashboard.component.html b/desktop/angular/src/app/pages/dashboard/dashboard.component.html new file mode 100644 index 00000000..aaf3077c --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/dashboard.component.html @@ -0,0 +1,281 @@ +
+ + + + +
+ + +
+
+ + + + +
+
+ + {{ blockedConnections }} +
+ +
+ + {{ activeConnections }} +
+ +
+ + {{ activeProfiles }} +
+ +
+ + + {{ dataIncoming | bytes }} + + + Available in
Portmaster Plus +
+
+ +
+ + + {{ dataOutgoing | bytes }} + + + Available in
Portmaster Plus +
+
+ +
+ + {{ activeIdentities }} + + Available in
Portmaster Pro +
+
+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
    +
  • +
    + + {{ countryNames[country.key] || country.key || 'N/A' }} +
    + {{ country.value }} +
  • +
+
+
+ + +
+ + + + + + + No applications have been blocked in the last 10 minutes. + + + +
    + +
  • +
    + + {{ profile.Name }} +
    + {{ entry.count }} +
  • +
    +
+
+
+ + + + + + + + + + Available in Portmaster Plus + + + + + + + + Available in Portmaster Plus + + + + + +
+ News is only available if intel data updates are enabled + +
+ +
+ Just a second, we're loading the latest news... +
+ + + + +
+ +

+ {{ card.title }} + +

+
+ + + +
+
+
+
+ {{ progress.percent }}% +
+
+
+ + + +
+
+
+ +
+ +
+
+ +
+
diff --git a/desktop/angular/src/app/pages/dashboard/dashboard.component.scss b/desktop/angular/src/app/pages/dashboard/dashboard.component.scss new file mode 100644 index 00000000..ba37e527 --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/dashboard.component.scss @@ -0,0 +1,166 @@ +:host { + @apply flex flex-row w-full gap-3 p-4 overflow-auto; +} + +.dashboard-grid { + @apply grid gap-4; + + align-items: stretch; + justify-items: stretch; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-areas: + "header header header header" + "feature feature feature feature" + "feature feature feature feature" + "stats stats news news" + "stats stats news news" + "charts charts charts charts" + "charts charts charts charts" + "blocked blocked countries countries" + "map map map map" + "bwvis-bar bwvis-bar bwvis-line bwvis-line"; +} + +:host-context(.min-width-1024px) { + .dashboard-grid { + grid-template-areas: + "header header header header" + "feature feature feature news" + "feature feature feature news" + "stats stats stats news" + "stats stats stats news" + "charts charts charts charts" + "countries countries map map" + "blocked blocked map map" + "bwvis-bar bwvis-bar bwvis-line bwvis-line"; + } +} + +#header { + grid-area: header; +} + +#features { + grid-area: feature; +} + +#stats { + grid-area: stats; +} + +#charts { + grid-area: charts; +} + +#countries { + grid-area: countries; +} + +#blocked { + grid-area: blocked; +} + +#connmap { + grid-area: map; +} + +#bwvis-bar { + grid-area: bwvis-bar; +} + +#bwvis-line { + grid-area: bwvis-line; +} + +#news { + grid-area: news; +} + +.auto-grid-3 { + @apply grid gap-4; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.auto-grid-4 { + @apply grid gap-4; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +app-dashboard-widget { + label { + @apply text-xs uppercase text-secondary font-light flex flex-row items-center gap-2 pb-2; + } + + .feature-card-list { + @apply grid gap-3 w-full; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } + + .mini-stats-list { + @apply grid gap-3 w-full; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } + + &#news { + + h1 { + @apply text-base; + @apply font-light; + } + + ::ng-deep markdown { + @apply font-light; + + a { + @apply underline text-blue; + } + + strong { + @apply font-medium; + } + } + } + +} + +::ng-deep #dashboard-map { + #world-group { + --map-bg: #111112; + --map-country-active: #424141; + --map-country-inactive: #2a2a2a; + --map-country-border-width: 1px; + --map-country-border-color: #1e1e1e; + --map-country-border-color-selected: #858585; + --map-country-blocked-primary: #858585; + --map-country-blocked-secondary: #402323; + + path { + fill: var(--map-country-active); + stroke: var(--map-bg); + stroke-width: var(--map-country-border-width); + stroke-linejoin: round; + } + + path.active { + color: #1d3c24; + fill: currentColor; + } + + path.hover { + color: #4fae4f; + fill: currentColor; + } + } +} + +.mini-stat { + @apply flex flex-col items-center justify-center py-3 px-2 bg-gray-300 rounded shadow; + + label { + @apply font-light uppercase text-xxs text-secondary -mb-2; + } + + span { + @apply text-xl text-blue; + } +} diff --git a/desktop/angular/src/app/pages/dashboard/dashboard.component.ts b/desktop/angular/src/app/pages/dashboard/dashboard.component.ts new file mode 100644 index 00000000..a07893aa --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/dashboard.component.ts @@ -0,0 +1,481 @@ +import { KeyValue } from "@angular/common"; +import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, QueryList, TrackByFunction, ViewChild, ViewChildren, forwardRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AppProfileService, BandwidthChartResult, ChartResult, Database, FeatureID, Netquery, PortapiService, SPNService, UserProfile, Verdict } from "@safing/portmaster-api"; +import { SfngDialogService, SfngTabGroupComponent } from "@safing/ui"; +import { Observable, catchError, filter, interval, map, repeat, retry, startWith, throwError } from "rxjs"; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { DefaultBandwidthChartConfig, SfngNetqueryLineChartComponent } from "src/app/shared/netquery/line-chart/line-chart"; +import { SPNAccountDetailsComponent } from "src/app/shared/spn-account-details"; +import { MAP_HANDLER, MapRef } from "../spn/map-renderer"; +import { CircularBarChartConfig, splitQueryResult } from "src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component"; +import { BytesPipe } from "src/app/shared/pipes/bytes.pipe"; +import { HttpErrorResponse } from "@angular/common/http"; + +interface BlockedProfile { + profileID: string; + count: number; +} + +interface BandwidthBarData { + profile: string; + profile_name: string; + series: 'sent' | 'received'; + value: number; + sent: number; + received: number; +} + +interface NewsCard { + title: string; + body: string; + url?: string; + footer?: string; + progress?: { + percent: number; + style: string; + } +} + +interface News { + cards: NewsCard[]; +} + +const newsResourceIdentifier = "all/intel/portmaster/news.yaml" + +@Component({ + selector: 'app-dashboard', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./dashboard.component.scss'], + templateUrl: './dashboard.component.html', + providers: [ + { provide: MAP_HANDLER, useExisting: forwardRef(() => DashboardPageComponent), multi: true }, + ] +}) +export class DashboardPageComponent implements OnInit, AfterViewInit { + @ViewChildren(SfngNetqueryLineChartComponent) + lineCharts!: QueryList; + + @ViewChild(SfngTabGroupComponent) + carouselTabGroup?: SfngTabGroupComponent; + + private readonly destroyRef = inject(DestroyRef); + private readonly netquery = inject(Netquery); + private readonly spn = inject(SPNService); + private readonly actionIndicator = inject(ActionIndicatorService); + private readonly cdr = inject(ChangeDetectorRef); + private readonly dialog = inject(SfngDialogService); + private readonly portapi = inject(PortapiService) + + resizeObserver!: ResizeObserver; + + blockedProfiles: BlockedProfile[] = [] + + connectionsPerCountry: { + [country: string]: number + } = {}; + + get countryNames(): { [country: string]: string } { + return this.mapRef?.countryNames || {}; + } + + bandwidthLineChart: BandwidthChartResult[] = []; + + bandwidthBarData: BandwidthBarData[] = []; + + readonly bandwidthBarConfig: CircularBarChartConfig = { + stack: 'profile_name', + seriesKey: 'series', + seriesLabel: d => { + if (d === 'sent') { + return 'Bytes Sent' + } + return 'Bytes Received' + }, + value: 'value', + ticks: 3, + colorAsClass: true, + series: { + 'sent': { + color: 'text-deepPurple-500 text-opacity-50', + }, + 'received': { + color: 'text-cyan-800 text-opacity-50', + } + }, + formatTick: (tick: number) => { + return new BytesPipe().transform(tick, '1.0-0') + }, + formatValue: (stack, series, value, data) => { + const bytes = new BytesPipe().transform + return `${stack}\nSent: ${bytes(data?.sent)}\nReceived: ${bytes(data?.received)}` + }, + formatStack: (sel, data) => { + const bytes = new BytesPipe().transform + + return sel + .call(sel => { + sel.append("text") + .attr("dy", "0") + .attr("y", "0") + .text(d => d) + }) + .call(sel => { + sel.append("text") + .attr("y", 0) + .attr("dy", "0.8rem") + .style("font-size", "0.6rem") + .text(d => { + const first = data.find(result => result.profile_name === d); + return `${bytes(first?.sent)} / ${bytes(first?.received)}` + }) + }) + } + } + + bwChartConfig = DefaultBandwidthChartConfig; + + activeConnections: number = 0; + blockedConnections: number = 0; + activeProfiles: number = 0; + activeIdentities = 0; + dataIncoming = 0; + dataOutgoing = 0; + connectionChart: ChartResult[] = []; + tunneldConnectionChart: ChartResult[] = []; + + countriesPerProfile: { [profile: string]: string[] } = {} + + profile: UserProfile | null = null; + + featureBw = false; + featureSPN = false; + + hoveredCard: NewsCard | null = null; + + features$ = this.spn.watchEnabledFeatures() + .pipe(takeUntilDestroyed()); + + trackCountry: TrackByFunction> = (_, ctr) => ctr.key; + trackApp: TrackByFunction = (_, bp) => bp.profileID; + + data: any; + + news?: News | 'pending' = 'pending'; + + private mapRef: MapRef | null = null; + + registerMap(ref: MapRef): void { + this.mapRef = ref; + + this.mapRef.onMapReady(() => { + this.updateMapCountries(); + }) + } + + private updateMapCountries() { + // this check is basically to make typescript happy ... + if (!this.mapRef) { + return; + } + + this.mapRef.worldGroup + .selectAll('path') + .classed('active', (d: any) => { + return !!this.connectionsPerCountry[d.properties.iso_a2]; + }); + } + + unregisterMap(ref: MapRef): void { + this.mapRef = null; + } + + onCarouselTabHover(card: NewsCard | null) { + this.hoveredCard = card; + } + + openAccountDetails() { + this.dialog.create(SPNAccountDetailsComponent, { + autoclose: true, + backdrop: 'light' + }) + } + + onCountryHover(code: string | null) { + if (!this.mapRef) { + return + } + + this.mapRef.worldGroup + .selectAll('path') + .classed('hover', (d: any) => { + return (d.properties.iso_a2 === code); + }); + } + + onProfileHover(profile: string | null) { + if (!this.mapRef) { + return + } + + this.mapRef.worldGroup + .selectAll('path') + .classed('hover', (d: any) => { + if (!profile) { + return false; + } + + return this.countriesPerProfile[profile]?.includes(d.properties.iso_a2); + }); + } + + ngAfterViewInit(): void { + interval(15000) + .pipe( + takeUntilDestroyed(this.destroyRef), + startWith(-1), + filter(() => this.hoveredCard === null) + ) + .subscribe(() => { + if (!this.carouselTabGroup) { + return + } + + let next = this.carouselTabGroup.activeTabIndex + 1 + if (next >= this.carouselTabGroup.tabs!.length) { + next = 0 + } + + this.carouselTabGroup.activateTab(next, "left") + }) + } + + async ngOnInit() { + this.portapi.getResource(newsResourceIdentifier) + .pipe( + repeat({ delay: 60000 }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: response => { + this.news = response; + this.cdr.markForCheck(); + }, + error: () => { + this.news = undefined; + this.cdr.markForCheck(); + } + }); + + this.netquery + .batch({ + bwBarChart: { + query: { + internal: { $eq: false }, + }, + select: [ + 'profile', + 'profile_name', + { + $sum: { + field: 'bytes_sent', + as: 'sent' + } + }, + { + $sum: { + field: 'bytes_received', + as: 'received' + } + }, + ], + groupBy: ['profile', 'profile_name'], + }, + + profileCount: { + select: [ + 'profile', + { + $count: { + field: '*', + as: 'totalCount' + } + } + ], + query: { + verdict: { $in: [Verdict.Block, Verdict.Drop] } + }, + groupBy: ['profile'], + databases: [Database.Live] + }, + + countryStats: { + select: [ + 'country', + { $count: { field: '*', as: 'totalCount' } }, + { $sum: { field: 'bytes_sent', as: 'bwout' } }, + { $sum: { field: 'bytes_received', as: 'bwin' } }, + ], + query: { + allowed: { $eq: true }, + }, + groupBy: ['country'], + databases: [Database.Live] + }, + + perCountryConns: { + select: ['profile', 'country', 'active', { $count: { field: '*', as: 'totalCount' } }], + query: { + allowed: { $eq: true }, + }, + groupBy: ['profile', 'country', 'active'], + databases: [Database.Live], + }, + + exitNodes: { + query: { tunneled: { $eq: true }, exit_node: { $ne: "" } }, + groupBy: ['exit_node'], + select: [ + 'exit_node', + { $count: { field: '*', as: 'totalCount' } } + ], + databases: [Database.Live], + } + }) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(response => { + // bandwidth bar chart + const barChartData = response.bwBarChart + .filter(value => (value.sent + value.received) > 0) + .sort((a, b) => (b.sent + b.received) - (a.sent + a.received)) + .slice(0, 10); + this.bandwidthBarData = splitQueryResult(barChartData, ['sent', 'received']) as BandwidthBarData[] + + // profileCount + this.blockedConnections = 0; + this.blockedProfiles = []; + + response.profileCount?.forEach(row => { + this.blockedConnections += row.totalCount; + this.blockedProfiles.push({ + profileID: row.profile!, + count: row.totalCount + }) + }); + + // countryStats + this.connectionsPerCountry = {}; + this.dataIncoming = 0; + this.dataOutgoing = 0; + + response.countryStats?.forEach(row => { + this.dataIncoming += row.bwin; + this.dataOutgoing += row.bwout; + + if (row.country === '') { + return + } + + this.connectionsPerCountry[row.country!] = row.totalCount || 0; + }) + + this.updateMapCountries() + + // perCountryConns + let profiles = new Set(); + + this.activeConnections = 0; + this.countriesPerProfile = {}; + + response.perCountryConns?.forEach(row => { + profiles.add(row.profile!); + + if (row.active) { + this.activeConnections += row.totalCount; + } + + const arr = (this.countriesPerProfile[row.profile!] || []); + arr.push(row.country!) + this.countriesPerProfile[row.profile!] = arr; + }); + + this.activeProfiles = profiles.size; + + // exitNodes + this.activeIdentities = response.exitNodes?.length || 0; + this.cdr.markForCheck(); + }) + + + // Charts + + this.netquery + .activeConnectionChart({}) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(result => { + this.connectionChart = result; + this.cdr.markForCheck(); + }) + + this.netquery + .bandwidthChart({}, undefined, 60) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(bw => { + this.bandwidthLineChart = bw; + this.cdr.markForCheck(); + }) + + this.netquery + .activeConnectionChart({ tunneled: { $eq: true } }) + .pipe( + repeat({ delay: 10000 }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(result => { + this.tunneldConnectionChart = result; + this.cdr.markForCheck(); + }) + + // SPN profile and enabled/allowed features + + this.spn + .profile$ + .pipe( + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (profile) => { + this.profile = profile || null; + this.featureBw = profile?.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || false; + this.featureSPN = profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) || false; + + // force a full change-detection cylce now! + this.cdr.detectChanges() + + // force re-draw of the charts after change-detection because the + // width may change now. + this.lineCharts?.forEach(chart => chart.redraw()) + + this.cdr.markForCheck(); + }, + }) + } + + /** Logs the user out of the SPN completely by purgin the user profile from the local storage */ + logoutCompletely(_: Event) { + this.spn.logout(true) + .subscribe(this.actionIndicator.httpObserver( + 'Logout', + 'You have been logged out of the SPN completely.' + )) + } +} diff --git a/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.html b/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.html new file mode 100644 index 00000000..0695555e --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.html @@ -0,0 +1,61 @@ +
+ + + + + + + + + + + +
+ + + + {{ feature?.Name }} + + +
+
+ +
+ + + + BETA +
+
+
+
+ + {{ (disabled ? 'Available in ' : '') + 'Portmaster ' + feature?.InPackage?.Name}} + {{ comingSoon ? ' - coming soon' : '' }} + {{ feature?.Comment }} + +
+ +
+ +
+
+ + Active + +
+ +
+ {{ feature?.InPackage?.Name }} + +
+
diff --git a/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.scss b/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.scss new file mode 100644 index 00000000..88f5fc61 --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.scss @@ -0,0 +1,60 @@ +.feature-card { + @apply flex flex-col p-4 bg-gray-300 rounded shadow w-full relative gap-2 overflow-hidden; + + .disabled-bg { + @apply absolute top-0 left-0 text-gray-500 opacity-50; + } + + &.disabled { + @apply opacity-80 shadow-inner; + } + + &.clickable { + @apply cursor-pointer; + &:hover { + @apply bg-gray-400; + } + } + + header { + @apply flex flex-row items-center justify-start gap-2 w-full; + + img { + @apply w-5 h-5; + filter: invert(1); + } + + &>span { + @apply text-base font-light; + } + } +} + +.ribbon { + width: 90px; + height: 100%; + overflow: hidden; + position: absolute; + top: 0px; + right: 0px; + z-index: 100; +} + +.ribbon__content { + left: -7px; + top: 25px; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + position: absolute; + display: block; + width: 125px; + padding: 2px 0; + text-shadow: 0 1px 0px rgba(0, 0, 0, .2); + text-transform: uppercase; + text-align: center; + border: 2px dotted #fff; + outline-color: #fff; + outline-width: 1px; + outline-style: solid; +} diff --git a/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.ts b/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.ts new file mode 100644 index 00000000..8355a3ba --- /dev/null +++ b/desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.ts @@ -0,0 +1,128 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { BoolSetting, ConfigService, Feature } from '@safing/portmaster-api'; +import { Subscription } from 'rxjs'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; + +@Component({ + selector: 'app-feature-card', + templateUrl: './feature-card.component.html', + styleUrls: ['./feature-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FeatureCardComponent implements OnChanges, OnDestroy { + private readonly cdr = inject(ChangeDetectorRef); + private readonly configService = inject(ConfigService); + private readonly router = inject(Router); + private readonly integration = inject(INTEGRATION_SERVICE); + + private configValueSubscription = Subscription.EMPTY; + + @Input() + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v) + } + get disabled() { return this._disabled } + _disabled = false; + + get comingSoon() { return this.feature?.ComingSoon || false } + + @Input() + feature?: Feature; + + planColor: string | null = null; + + configValue: boolean | undefined = undefined; + + ngOnChanges(changes: SimpleChanges): void { + if ('feature' in changes) { + this.configValueSubscription.unsubscribe(); + this.configValueSubscription = Subscription.EMPTY; + + if (!!this.feature?.ConfigKey) { + this.configValueSubscription = + this.configService.watch(this.feature!.ConfigKey) + .subscribe(value => { + this.configValue = value; + this.cdr.markForCheck(); + }); + } + + if (this.feature?.InPackage?.HexColor) { + this.planColor = getContrastFontColor(this.feature.InPackage.HexColor); + // console.log(this.feature.InPackage.HexColor, this.planColor) + this.cdr.markForCheck(); + } + } + } + + ngOnDestroy() { + this.configValueSubscription.unsubscribe(); + } + + updateSettingsValue(newValue: boolean) { + this.configService.save(this.feature!.ConfigKey, newValue) + .subscribe() + } + + navigateToConfigScope() { + if (this.disabled) { + this.integration.openExternal("https://safing.io/pricing?source=Portmaster") + return; + } + + let key: string | undefined; + if (this.feature?.ConfigScope) { + key = 'config:' + this.feature?.ConfigScope; + } else { + key = this.feature?.ConfigKey; + } + + if (!key) { + return + } + + + this.router.navigate(['/settings'], { + queryParams: { + setting: key, + } + }) + } +} + +function parseColor(input: string): number[] { + if (input.substr(0, 1) === '#') { + const collen = (input.length - 1) / 3; + const fact = [17, 1, 0.062272][collen - 1]; + return [ + Math.round(parseInt(input.substr(1, collen), 16) * fact), + Math.round(parseInt(input.substr(1 + collen, collen), 16) * fact), + Math.round(parseInt(input.substr(1 + 2 * collen, collen), 16) * fact), + ]; + } + + return input + .split('(')[1] + .split(')')[0] + .split(',') + .map((x) => +x); +} + +function getContrastFontColor(bgColor: string): string { + // if (red*0.299 + green*0.587 + blue*0.114) > 186 use #000000 else use #ffffff + // based on https://stackoverflow.com/a/3943023 + + let col = bgColor; + if (bgColor.startsWith('#') && bgColor.length > 7) { + col = bgColor.slice(0, 7); + } + const [r, g, b] = parseColor(col); + + if (r * 0.299 + g * 0.587 + b * 0.114 > 186) { + return '#000000'; + } + + return '#ffffff'; +} diff --git a/desktop/angular/src/app/pages/monitor/index.ts b/desktop/angular/src/app/pages/monitor/index.ts new file mode 100644 index 00000000..c21908c8 --- /dev/null +++ b/desktop/angular/src/app/pages/monitor/index.ts @@ -0,0 +1 @@ +export { MonitorPageComponent } from './monitor'; diff --git a/desktop/angular/src/app/pages/monitor/monitor.html b/desktop/angular/src/app/pages/monitor/monitor.html new file mode 100644 index 00000000..1d6fb177 --- /dev/null +++ b/desktop/angular/src/app/pages/monitor/monitor.html @@ -0,0 +1,46 @@ +
+ + +
+
+ +
+

+ Network Activity + +

+ + + + + + Network history data available as of {{ data.first | date }}. ({{ data.count }} connections) + + Clear + + + + + No network history data available. + + Enable + + + Available in Portmaster Plus + + + + + + + Use the search bar and drop downs to search and filter the last 10 minutes of network traffic. + Optionally, search all network history data if enabled. + +
+ + + +
diff --git a/desktop/angular/src/app/pages/monitor/monitor.scss b/desktop/angular/src/app/pages/monitor/monitor.scss new file mode 100644 index 00000000..12345b56 --- /dev/null +++ b/desktop/angular/src/app/pages/monitor/monitor.scss @@ -0,0 +1,49 @@ +:host { + overflow: hidden; + flex-direction: row; + flex-grow: 1; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + padding-left: 1.7rem; + padding-right: 0.8rem; + + .header, + .content { + padding: 0; + margin: 0; + } + + .header { + padding-top: 0.9rem; + + .breadcrumbs { + font-size: 0.715rem; + font-weight: 500; + color: #cacaca; + user-select: none; + display: flex; + + span:first-child { + opacity: .55; + font-weight: 400; + margin-right: 4px; + + &:hover { + opacity: 1; + } + } + + svg.arrow { + width: 1rem; + padding: 0; + margin: 0; + + .inner { + stroke: white; + } + } + } + } +} diff --git a/desktop/angular/src/app/pages/monitor/monitor.ts b/desktop/angular/src/app/pages/monitor/monitor.ts new file mode 100644 index 00000000..d6ce3374 --- /dev/null +++ b/desktop/angular/src/app/pages/monitor/monitor.ts @@ -0,0 +1,77 @@ +import { Component, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { BoolSetting, ConfigService, Database, FeatureID, Netquery, SPNService } from '@safing/portmaster-api'; +import { Subject, interval, map, merge, repeat } from 'rxjs'; +import { SessionDataService } from 'src/app/services'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { fadeInAnimation, moveInOutListAnimation } from 'src/app/shared/animations'; + +@Component({ + templateUrl: './monitor.html', + styleUrls: ['../page.scss', './monitor.scss'], + providers: [], + animations: [fadeInAnimation, moveInOutListAnimation], +}) +export class MonitorPageComponent { + session = inject(SessionDataService); + netquery = inject(Netquery); + reload = new Subject(); + + configService = inject(ConfigService); + uai = inject(ActionIndicatorService); + + historyEnabled = inject(ConfigService) + .watch('history/enable'); + + canUseHistory = inject(SPNService).profile$ + .pipe( + map(profile => { + return profile?.current_plan?.feature_ids?.includes(FeatureID.History) || false; + }) + ); + + history = inject(Netquery) + .query({ + select: [ + { + $min: { + field: "started", + as: "first_connection", + }, + }, + { + $count: { + field: "*", + as: "totalCount" + } + } + ], + databases: [Database.History] + }, 'monitor-get-first-history-connection') + .pipe( + repeat({ delay: () => merge(interval(10000), this.reload) }), + map(result => { + if (!result.length || result[0].totalCount === 0) { + return null + } + + return { + first: new Date(result[0].first_connection), + count: result[0].totalCount, + } + }), + takeUntilDestroyed() + ); + + enableHistory() { + this.configService.save('history/enable', true) + .subscribe(); + } + + clearHistoryData() { + this.netquery.cleanProfileHistory([]) + .subscribe(() => { + this.reload.next(); + }) + } +} diff --git a/desktop/angular/src/app/pages/page.scss b/desktop/angular/src/app/pages/page.scss new file mode 100644 index 00000000..1977b027 --- /dev/null +++ b/desktop/angular/src/app/pages/page.scss @@ -0,0 +1,6 @@ +:host { + display : flex; + flex-direction: column; + width : 100%; + height : 100%; +} diff --git a/desktop/angular/src/app/pages/settings/settings.html b/desktop/angular/src/app/pages/settings/settings.html new file mode 100644 index 00000000..f85c752b --- /dev/null +++ b/desktop/angular/src/app/pages/settings/settings.html @@ -0,0 +1,26 @@ + + +
+

+ Global Settings + +

+
+ + + diff --git a/desktop/angular/src/app/pages/settings/settings.scss b/desktop/angular/src/app/pages/settings/settings.scss new file mode 100644 index 00000000..bc178ab8 --- /dev/null +++ b/desktop/angular/src/app/pages/settings/settings.scss @@ -0,0 +1,83 @@ +.header-title { + display: flex; + width: 100%; + padding-left: 3rem; + padding-right: 1.25rem; + margin-bottom: 0.5rem; + align-items: center; + height: 3rem; + flex-shrink: 0; + + h1{ + flex-grow: unset; + } + + fa-icon[icon*="question-circle"]{ + margin-left: 0.35rem; + } +} + +.card-title.meta { + div { + display: inline-block; + @apply mr-2; + } +} + +.columns { + width : 100%; + display : flex; + flex-direction: row; +} + +.meta { + + span:first-of-type { + @apply text-secondary; + @apply mr-1; + } +} + +.col { + flex-grow: 1; +} + +.unstable { + @apply text-xs; + @apply uppercase; + color: theme('colors.info.yellow'); +} + +sfng-accordion-group { + @apply pl-12; + @apply pr-4; // align with the scroll bar on the right side + @apply my-4; +} + +div.tableFixHead { + @apply mt-4; + @apply rounded-t; + + &:not(.empty) { + @apply rounded; + } + + max-height: 16rem; +} + +.cdk-row.unused { + opacity: 0.4; +} + +.card-actions { + display : flex; + align-items: center; + + * { + @apply ml-2; + } + + app-menu-trigger { + display: inline-block; + } +} diff --git a/desktop/angular/src/app/pages/settings/settings.ts b/desktop/angular/src/app/pages/settings/settings.ts new file mode 100644 index 00000000..3f36f9bd --- /dev/null +++ b/desktop/angular/src/app/pages/settings/settings.ts @@ -0,0 +1,133 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ConfigService, Setting } from '@safing/portmaster-api'; +import { Subscription } from 'rxjs'; +import { StatusService, VersionStatus } from 'src/app/services'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { fadeInAnimation } from 'src/app/shared/animations'; +import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting'; + +@Component({ + templateUrl: './settings.html', + styleUrls: [ + '../page.scss', + './settings.scss' + ], + animations: [fadeInAnimation] +}) +export class SettingsComponent implements OnInit, OnDestroy { + /** @private The current search term for the settings. */ + searchTerm: string = ''; + + /** @private All settings currently displayed. */ + settings: Setting[] = []; + + /** @private The available and selected resource versions. */ + versions: VersionStatus | null = null; + + /** + * @private + * The key of the setting to highligh, if any ... + */ + highlightSettingKey: string | null = null; + + /** Subscription to watch all available settings. */ + private subscription = Subscription.EMPTY; + + constructor( + public configService: ConfigService, + public statusService: StatusService, + private actionIndicator: ActionIndicatorService, + private route: ActivatedRoute, + ) { } + + ngOnInit(): void { + this.subscription = new Subscription(); + + this.loadSettings(); + + // Request the current resource versions once. We add + // it to the subscription to prevent a memory leak in + // case the user leaves the page before the versions + // have been loaded. + const versionSub = this.statusService.getVersions() + .subscribe(version => this.versions = version); + + this.subscription.add(versionSub); + + const querySub = this.route.queryParamMap + .subscribe( + params => { + this.highlightSettingKey = params.get('setting'); + } + ) + this.subscription.add(querySub); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + /** + * Loads all settings from the portmaster. + */ + private loadSettings() { + const configSub = this.configService.query('') + .subscribe(settings => this.settings = settings); + this.subscription.add(configSub); + } + + /** + * @private + * SaveSettingEvent is emitted by the settings-view + * component when a value has been changed and should be saved. + * + * @param event The save-settings event + */ + saveSetting(event: SaveSettingEvent) { + let idx = this.settings.findIndex(setting => setting.Key === event.key); + if (idx < 0) { + return; + } + + const setting = { + ...this.settings[idx], + } + + if (event.isDefault) { + delete (setting['Value']); + } else { + setting.Value = event.value; + } + + this.configService.save(setting) + .subscribe({ + next: () => { + if (!!event.accepted) { + event.accepted(); + } + + this.settings[idx] = setting; + + // copy the settings into a new array so we trigger + // an input update due to changed array identity. + this.settings = [...this.settings]; + + // for the release level setting we need to + // to a page-reload since portmaster will now + // return more settings. + if (setting.Key === 'core/releaseLevel') { + this.loadSettings(); + } + }, + error: err => { + if (!!event.rejected) { + event.rejected(err); + } + + this.actionIndicator.error('Failed to save setting', err); + console.error(err); + } + }) + } +} diff --git a/desktop/angular/src/app/pages/spn/country-details/country-details.html b/desktop/angular/src/app/pages/spn/country-details/country-details.html new file mode 100644 index 00000000..f4e43963 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-details/country-details.html @@ -0,0 +1,154 @@ +

+ + {{ countryName }} + + + + + +

+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Total Nodes + {{ totalAliveCount }}
+ + + by Safing + + {{ safingNodeCount }}
+ + + by Community + + {{ communityNodeCount }}
+ Exit Nodes + {{ exitNodeCount }}
+ + + by Safing + + {{ safingExitNodeCount }}
+ + + by Community + + {{ communityExitNodeCount }}
+ Nodes In Use + {{ activeNodeCount }}
+ + + by Safing + + {{ activeSafingNodeCount }}
+ + + by Community + + {{ activeCommunityNodeCount }}
+
+
+ + + + +
+ The following Apps have connections that are routed through the + SPN and use an + exit node in {{ countryName }} ({{ countryCode }}): + + + + + + + + +
+ + {{ app.profile.Name }} + + {{ app.count }} connections + +
+
+ + + + + + +
+ +
+ + + + +
+
+
+
+
+ +
diff --git a/desktop/angular/src/app/pages/spn/country-details/country-details.ts b/desktop/angular/src/app/pages/spn/country-details/country-details.ts new file mode 100644 index 00000000..3fa34f1c --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-details/country-details.ts @@ -0,0 +1,217 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnChanges, OnDestroy, OnInit, Optional, Output, SimpleChanges, TrackByFunction } from "@angular/core"; +import { AppProfile, AppProfileService, Netquery } from '@safing/portmaster-api'; +import { SFNG_DIALOG_REF, SfngDialogRef, SfngDialogService } from "@safing/ui"; +import { Subscription, forkJoin, of, switchMap } from 'rxjs'; +import { repeat } from 'rxjs/operators'; +import { MapPin, MapService } from './../map.service'; +import { PinDetailsComponent } from './../pin-details/pin-details'; + +@Component({ + templateUrl: './country-details.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + `:host{ + display: block; + min-width: 630px; + height: 400px; + overflow: hidden; + }` + ] +}) +export class CountryDetailsComponent implements OnInit, OnChanges, OnDestroy { + /** Subscription to poll map pins and profiles. */ + private subscription = Subscription.EMPTY; + + /** The two letter ISO country code */ + @Input() + countryCode: string = ''; + + /** The name of the country */ + @Input() + countryName: string = ''; + + /** Emits the ID of the pin that is hovered in the list. null if no pin is hovered */ + @Output() + pinHover = new EventEmitter(); + + /** @private - The list of pins available in this country */ + pins: MapPin[] = []; + + /** @private - A list of app profiles that use this country as an exit node */ + profiles: { profile: AppProfile, count: number }[] = []; + + /** @private - A {@link TrackByFunction} for all profiles that use this country for exit */ + trackProfile: TrackByFunction = (_: number, profile: this['profiles'][0]) => `${profile.profile.Source}/${profile.profile.ID}`; + + /** The number of alive nodes in this country */ + totalAliveCount = 0; + + /** The number of exit nodes in this country */ + exitNodeCount = 0; + + /** The number of active (used) nodes in this country */ + activeNodeCount = 0; + + /** The number of active (used) nodes operated by safing */ + activeSafingNodeCount = 0; + + /** The number of active (used) nodes operated by the community */ + activeCommunityNodeCount = 0; + + /** The number of nodes operated by safing */ + safingNodeCount = 0; + + /** The number of exit nodes operated by safing */ + safingExitNodeCount = 0; + + /** The number of nodes operated by a community member */ + communityNodeCount = 0; + + /** The number of exit ndoes operated by the community */ + communityExitNodeCount = 0; + + /** holds the text format of a netquery search to show all connections that exit in this country */ + filterConnectionsByCountryNodes = ''; + + constructor( + private mapService: MapService, + private netquery: Netquery, + private appService: AppProfileService, + private cdr: ChangeDetectorRef, + private dialog: SfngDialogService, + @Inject(SFNG_DIALOG_REF) @Optional() public dialogRef?: SfngDialogRef, + ) { } + + openPinDetails(id: string) { + this.dialog.create(PinDetailsComponent, { + data: id, + backdrop: false, + autoclose: true, + dragable: true, + }) + } + + ngOnInit() { + // if we got opened as a dialog we get the code and name of the country + // from the dialogRef.data field. + if (!!this.dialogRef) { + this.countryCode = this.dialogRef.data.code; + this.countryName = this.dialogRef.data.name; + } + + this.subscription.unsubscribe(); + + this.subscription = + this.mapService + .pins$ + .pipe( + switchMap(pins => { + // get a list of pins in that country + const countryPins = pins.filter(pin => pin.entity.Country === this.countryCode); + + // prepare a netquery query that loads the IDs of all profiles that use one of the countries + // pins as an exit node. Then, map those IDs to the actual app profile object + const profiles = this.netquery + .query({ + select: [ + 'profile', + { $count: { field: '*', as: 'totalCount' } } + ], + groupBy: ['profile'], + query: { + 'exit_node': { + $in: countryPins.map(pin => pin.pin.ID), + } + } + }, 'get-connections-per-profile-in-country') + .pipe( + switchMap(queryResult => { + if (queryResult.length === 0) { + return of([]); + } + + return forkJoin( + queryResult.map(row => forkJoin({ + profile: this.appService.getAppProfile(row.profile!), + count: of(row.totalCount), + }) + ) + ) + }), + ); + + return forkJoin({ + pins: of(countryPins), + profiles: profiles, + }) + } + ), + repeat({ + delay: 5000 + }), + ) + .subscribe(result => { + this.pins = result.pins; + this.profiles = result.profiles + + this.activeNodeCount = 0; + this.activeCommunityNodeCount = 0; + this.activeSafingNodeCount = 0; + this.exitNodeCount = 0; + this.safingNodeCount = 0; + this.communityNodeCount = 0; + this.safingExitNodeCount = 0; + this.communityExitNodeCount = 0; + + this.pins.forEach(pin => { + if (pin.isOffline) { + return + } + this.totalAliveCount++; + + if (pin.pin.VerifiedOwner === 'Safing') { + this.safingNodeCount++; + + if (pin.isExit) { + this.exitNodeCount++; + this.safingExitNodeCount++; + } + if (pin.isActive) { + this.activeSafingNodeCount++; + this.activeNodeCount++; + } + + } else { + this.communityNodeCount++; + + if (pin.isExit) { + this.exitNodeCount++; + this.communityExitNodeCount++; + } + if (pin.isActive) { + this.activeCommunityNodeCount++; + this.activeNodeCount++; + } + } + }) + + // create a netquery text-query in the format of "exit_node: exit_node: ..." + this.filterConnectionsByCountryNodes = this.pins.map(pin => `exit_node:${pin.pin.ID}`).join(" ") + + this.cdr.markForCheck(); + }) + } + + ngOnChanges(changes: SimpleChanges): void { + // if we are rendered as a regular component (not as a dialog) we need to + // handle updates to our @Inputs(). + // just let ngOnInit() do it's thing if the countryCode changed. + if (!!changes['countryCode']) { + this.ngOnInit(); + } + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/pages/spn/country-details/index.ts b/desktop/angular/src/app/pages/spn/country-details/index.ts new file mode 100644 index 00000000..3ff2c685 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-details/index.ts @@ -0,0 +1 @@ +export * from './country-details'; diff --git a/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.html b/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.html new file mode 100644 index 00000000..6ea2166f --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.html @@ -0,0 +1,25 @@ + + + + + {{ countryName }} + + + + + + Safing Nodes: + {{ safingNodes.length }} + + + + + Community Nodes: + {{ communityNodes.length }} + + + + + Click country for details + + diff --git a/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.scss b/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.scss new file mode 100644 index 00000000..be98030c --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.scss @@ -0,0 +1,40 @@ +:host { + @apply flex flex-row items-center justify-center; +} + +.country-content-wrapper { + @apply flex flex-col gap-2 items-center justify-center bg-gray-200 border bg-opacity-50 border-gray-600 border-opacity-25; +} + +.country-name { + @apply text-sm flex flex-row gap-1 items-center justify-center bg-gray-100 bg-opacity-50 py-2 w-full; +} + +.country-stats { + @apply flex flex-col gap-2 items-start py-2 px-4; + + &>span { + @apply flex flex-row gap-1 items-center; + @apply text-xs font-light; + } + + .count { + @apply text-sm font-normal; + } +} + +.country-stats--safing { + svg polygon { + fill: #0376bb; + stroke: #0376bb; + transform: scale(1.15) + } +} + +.country-stats--community { + svg circle { + fill: #239215; + stroke: #239215; + transform: scale(1.15) + } +} diff --git a/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.ts b/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.ts new file mode 100644 index 00000000..2af05a26 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-overlay/country-overlay.ts @@ -0,0 +1,75 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { takeWhile } from 'rxjs/operators'; +import { MapPin, MapService } from './../map.service'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'spn-map-country-overlay', + templateUrl: './country-overlay.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: [ + './country-overlay.scss' + ] +}) +export class CountryOverlayComponent implements OnInit, OnChanges, OnDestroy { + /** The two-letter ISO code of the country */ + @Input() + countryCode!: string; + + /** The (english) name of the country */ + @Input() + countryName!: string; + + /** all nodes in this country operated by Safing */ + safingNodes: MapPin[] = []; + + /** all nodes in this country operated by a community member */ + communityNodes: MapPin[] = []; + + /** used to trigger a reload onChanges */ + private reload$ = new BehaviorSubject(undefined); + + constructor( + private mapService: MapService, + private cdr: ChangeDetectorRef, + ) { } + + ngOnChanges(changes: SimpleChanges): void { + this.reload$.next(); + } + + ngOnInit(): void { + combineLatest([ + this.mapService.pins$, + this.reload$ + ]) + .pipe( + takeWhile(() => !this.reload$.closed), + map(([pins]) => pins.filter(pin => pin.entity.Country === this.countryCode)), + ) + .subscribe(pinsInCountry => { + this.safingNodes = []; + this.communityNodes = []; + + pinsInCountry.forEach(pin => { + if (pin.isOffline && !pin.isActive) { + return + } + + if (pin.pin.VerifiedOwner === 'Safing') { + this.safingNodes.push(pin) + } else { + this.communityNodes.push(pin) + } + }) + + this.cdr.markForCheck(); + }) + } + + ngOnDestroy(): void { + this.reload$.complete(); + } +} + diff --git a/desktop/angular/src/app/pages/spn/country-overlay/index.ts b/desktop/angular/src/app/pages/spn/country-overlay/index.ts new file mode 100644 index 00000000..2e61e978 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/country-overlay/index.ts @@ -0,0 +1 @@ +export * from './country-overlay'; diff --git a/desktop/angular/src/app/pages/spn/index.ts b/desktop/angular/src/app/pages/spn/index.ts new file mode 100644 index 00000000..cc24eeea --- /dev/null +++ b/desktop/angular/src/app/pages/spn/index.ts @@ -0,0 +1 @@ +export * from './spn-page'; diff --git a/desktop/angular/src/app/pages/spn/map-legend/index.ts b/desktop/angular/src/app/pages/spn/map-legend/index.ts new file mode 100644 index 00000000..111ec8c0 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map-legend/index.ts @@ -0,0 +1 @@ +export * from './map-legend'; diff --git a/desktop/angular/src/app/pages/spn/map-legend/map-legend.html b/desktop/angular/src/app/pages/spn/map-legend/map-legend.html new file mode 100644 index 00000000..07cf2a9b --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map-legend/map-legend.html @@ -0,0 +1,54 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Safing Nodes + {{ safingNodeCount }}
+ + + used as Transit + + {{ safingActiveCount }}
+ + + used as Exit + + {{ safingExitCount }}
+ + Community Nodes + {{ communityNodeCount }}
+ + + used as Transit + + {{ communityActiveCount }}
+ + + used as Exit + + {{ communityExitCount }}
+
diff --git a/desktop/angular/src/app/pages/spn/map-legend/map-legend.ts b/desktop/angular/src/app/pages/spn/map-legend/map-legend.ts new file mode 100644 index 00000000..e561111e --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map-legend/map-legend.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { Subscription } from 'rxjs'; +import { MapService } from './../map.service'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'spn-map-legend', + templateUrl: './map-legend.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpnMapLegendComponent implements OnInit, OnDestroy { + private subscription = Subscription.EMPTY; + + safingNodeCount = 0; + safingExitCount = 0; + safingActiveCount = 0; + + communityNodeCount = 0; + communityExitCount = 0; + communityActiveCount = 0; + + constructor( + private mapService: MapService, + private cdr: ChangeDetectorRef, + ) { } + + ngOnInit() { + this.subscription = this.mapService + .pins$ + .subscribe(pins => { + this.safingActiveCount = 0; + this.safingExitCount = 0; + this.safingNodeCount = 0; + this.communityActiveCount = 0; + this.communityExitCount = 0; + this.communityNodeCount = 0; + + pins.forEach(pin => { + if (pin.pin.VerifiedOwner === 'Safing') { + if (pin.isActive) { + this.safingActiveCount++; + } + + if (pin.isExit) { + this.safingExitCount++ + } + + this.safingNodeCount++ + } else { + if (pin.isActive) { + this.communityActiveCount++; + } + + if (pin.isExit) { + this.communityExitCount++; + } + + this.communityNodeCount++; + } + }) + + this.cdr.markForCheck(); + }) + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/pages/spn/map-renderer/index.ts b/desktop/angular/src/app/pages/spn/map-renderer/index.ts new file mode 100644 index 00000000..60ff8a16 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map-renderer/index.ts @@ -0,0 +1 @@ +export * from './map-renderer'; diff --git a/desktop/angular/src/app/pages/spn/map-renderer/map-renderer.ts b/desktop/angular/src/app/pages/spn/map-renderer/map-renderer.ts new file mode 100644 index 00000000..bea5ee57 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map-renderer/map-renderer.ts @@ -0,0 +1,383 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, Inject, InjectionToken, Input, OnDestroy, OnInit, Optional, inject } from '@angular/core'; +import { GeoPath, GeoPermissibleObjects, GeoProjection, Selection, ZoomTransform, geoMercator, geoPath, json, pointer, select, zoom, zoomIdentity } from 'd3'; +import { feature } from 'topojson-client'; + + +export type MapRoot = Selection; +export type WorldGroup = Selection + +export interface CountryEvent { + event?: MouseEvent; + countryCode: string; + countryName: string; +} + +export interface MapRef { + onMapReady(cb: () => any): void; + onZoomPan(cb: () => any): void; + onCountryHover(cb: (_: CountryEvent | null) => void): void; + onCountryClick(cb: (_: CountryEvent) => void): void; + select(selection: string): Selection | null; + + countryNames: { [key: string]: string }; + root: MapRoot; + projection: GeoProjection; + zoomScale: number; + worldGroup: WorldGroup; +} + +export interface MapHandler { + registerMap(ref: MapRef): void; + unregisterMap(ref: MapRef): void; +} + +export const MAP_HANDLER = new InjectionToken('MAP_HANDLER'); + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'spn-map-renderer', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + styleUrls: [ + './map-style.scss' + ], +}) +export class MapRendererComponent implements OnInit, AfterViewInit, OnDestroy { + static readonly Rotate = 0; // so [-0, 0] is the initial center of the projection + static readonly Maxlat = 83; // clip northern and southern pols (infinite in mercator) + static readonly MarkerSize = 4; + static readonly LineAnimationDuration = 200; + + private readonly destroyRef = inject(DestroyRef); + private destroyed = false; + + countryNames: { + [countryCode: string]: string + } = {} + + // SVG group elements + private svg: MapRoot | null = null; + worldGroup!: WorldGroup; + + // Projection and line rendering functions + projection!: GeoProjection; + zoomScale: number = 1 + + private pathFunc!: GeoPath; + + get root() { + return this.svg! + } + + @Input() + mapId: string = 'map' + + constructor( + private mapRoot: ElementRef, + private cdr: ChangeDetectorRef, + @Inject(MAP_HANDLER) @Optional() private overlays: MapHandler[], + ) { } + + ngOnInit(): void { + this.overlays?.forEach(ov => { + ov.registerMap(this) + }) + + this.cdr.detach() + } + + select(selector: string) { + if (!this.svg) { + return null + } + + return this.svg.select(selector); + } + + private _readyCb: (() => void)[] = []; + onMapReady(cb: () => void) { + this._readyCb.push(cb); + } + + private _zoomCb: (() => void)[] = []; + onZoomPan(cb: () => void) { + this._zoomCb.push(cb) + } + + private _countryHoverCb: ((e: CountryEvent | null) => void)[] = []; + onCountryHover(cb: (e: CountryEvent | null) => void) { + this._countryHoverCb.push(cb); + } + + private _countryClickCb: ((e: CountryEvent) => void)[] = []; + onCountryClick(cb: (e: CountryEvent) => void) { + this._countryClickCb.push(cb) + } + + async ngAfterViewInit() { + await this.renderMap() + + const observer = new ResizeObserver(() => { + this.renderMap() + }) + + this.destroyRef.onDestroy(() => { + observer.unobserve(this.mapRoot.nativeElement) + observer.disconnect() + }) + + observer.observe(this.mapRoot.nativeElement); + } + + async renderMap() { + if (this.destroyed) { + return; + } + + if (!!this.svg) { + this.svg.remove() + } + + const map = select(this.mapRoot.nativeElement); + + // setup the basic SVG elements + this.svg = map + .append('svg') + .attr('id', this.mapId) + .attr("xmlns", "http://www.w3.org/2000/svg") + .attr('width', '100%') + .attr('preserveAspectRation', 'none') + .attr('height', '100%') + + this.worldGroup = this.svg.append('g').attr('id', 'world-group') + + // load the world-map data and start rendering + const world = await json('/assets/world-50m.json'); + + // actually render the countries + const countries = (feature(world, world.objects.countries) as any); + + this.setupProjection(); + await this.setupZoom(countries); + + // we need to await the initial world render here because otherwise + // the initial renderPins() will not be able to update the country attributes + // and cause a delay before the state of the country (has-nodes, is-blocked, ...) + // is visible. + this.renderWorld(countries); + + this._readyCb.forEach(cb => cb()); + } + + ngOnDestroy() { + this.destroyed = true; + + this.overlays?.forEach(ov => ov.unregisterMap(this)); + + this._countryClickCb = []; + this._countryHoverCb = []; + this._readyCb = []; + this._zoomCb = []; + + if (!this.svg) { + return; + } + + this.svg.remove(); + this.svg = null; + } + + private renderWorld(countries: any) { + // actually render the countries + const data = countries.features; + const self = this; + + data.forEach((country: any) => { + this.countryNames[country.properties.iso_a2] = country.properties.name + }) + + this.worldGroup.selectAll() + .data(data) + .enter() + .append('path') + .attr('countryCode', (d: any) => d.properties.iso_a2) + .attr('name', (d: any) => d.properties.name) + .attr('d', this.pathFunc) + .on('mouseenter', function (event: MouseEvent) { + const country = select(this).datum() as any; + const countryEvent: CountryEvent = { + event: event, + countryCode: country.properties.iso_a2, + countryName: country.properties.name, + } + + self._countryHoverCb.forEach(cb => cb(countryEvent)) + }) + .on('mouseout', function (event: MouseEvent) { + self._countryHoverCb.forEach(cb => cb(null)) + }) + .on('click', function (event: MouseEvent) { + const country = select(this).datum() as any; + const countryEvent: CountryEvent = { + event: event, + countryCode: country.properties.iso_a2, + countryName: country.properties.name, + } + + const loc = self.projection.invert!([event.clientX, event.clientY]) + + console.log(loc) + + self._countryClickCb.forEach(cb => cb(countryEvent)) + }) + } + + private setupProjection() { + const size = this.mapRoot.nativeElement.getBoundingClientRect(); + + this.projection = geoMercator() + .rotate([MapRendererComponent.Rotate, 0]) + .scale(1) + .translate([size.width / 2, size.height / 2]); + + + // path is used to update the SVG path to match our mercator projection + this.pathFunc = geoPath().projection(this.projection); + } + + private async setupZoom(countries: any) { + if (!this.svg) { + return + } + + // create a copy of countries + countries = { + ...countries, + features: [...countries.features] + } + + // remove Antarctica from the feature set so projection.fitSize ignores it + // and better aligns the rest of the world :) + const aqIdx = countries.features.findIndex((p: GeoJSON.Feature) => p.properties?.iso_a2 === "AQ"); + if (aqIdx >= 0) { + countries.features.splice(aqIdx, 1) + } + + const size = this.mapRoot.nativeElement.getBoundingClientRect(); + + this.projection.fitSize([size.width, size.height], countries) + + //this.projection.fitWidth(size.width, countries) + //this.projection.fitHeight(size.height, countries) + + // returns the top-left and the bottom-right of the current projection + const mercatorBounds = () => { + const yaw = this.projection.rotate()[0]; + const xymax = this.projection([-yaw + 180 - 1e-6, -MapRendererComponent.Maxlat])!; + const xymin = this.projection([-yaw - 180 + 1e-6, MapRendererComponent.Maxlat])!; + return [xymin, xymax]; + } + + const s = this.projection.scale() + const scaleExtent = [s, s * 10] + + const transform = zoomIdentity + .scale(this.projection.scale()) + .translate(this.projection.translate()[0], this.projection.translate()[1]); + + // whenever the users zooms we need to update our groups + // individually to apply the zoom effect. + let tlast = { + x: 0, + y: 0, + k: 0, + } + + const self = this; + + let z = zoom() + .scaleExtent(scaleExtent as [number, number]) + .on('zoom', (e) => { + const t: ZoomTransform = e.transform; + + if (t.k != tlast.k) { + let p = pointer(e) + let scrollToMouse = () => { }; + + if (!!p && !!p[0]) { + const tp = this.projection.translate(); + const coords = this.projection!.invert!(p) + scrollToMouse = () => { + const newPos = this.projection(coords!)!; + const yaw = this.projection.rotate()[0]; + this.projection.translate([tp[0], tp[1] + (p[1] - newPos[1])]) + this.projection.rotate([yaw + 360.0 * (p[0] - newPos[0]) / size.width * scaleExtent[0] / t.k, 0, 0]) + } + } + + this.projection.scale(t.k); + scrollToMouse(); + + } else { + let dy = t.y - tlast.y; + const dx = t.x - tlast.x; + const yaw = this.projection.rotate()[0] + const tp = this.projection.translate(); + + // use x translation to rotate based on current scale + this.projection.rotate([yaw + 360.0 * dx / size.width * scaleExtent[0] / t.k, 0, 0]) + // use y translation to translate projection clamped to bounds + let bounds = mercatorBounds(); + if (bounds[0][1] + dy > 0) { + dy = -bounds[0][1]; + } else if (bounds[1][1] + dy < size.height) { + dy = size.height - bounds[1][1]; + } + this.projection.translate([tp[0], tp[1] + dy]); + } + + tlast = { + x: t.x, + y: t.y, + k: t.k, + } + + // finally, re-render the SVG shapes according to the new projection + this.worldGroup.selectAll('path') + .attr('d', this.pathFunc) + + + this._zoomCb.forEach(cb => cb()); + }); + + this.svg.call(z) + this.svg.call(z.transform, transform); + } + + public getCoords(lat: number, lng: number) { + const loc = this.projection([lng, lat]); + if (!loc) { + return null; + } + + const rootElem = this.mapRoot.nativeElement.getBoundingClientRect(); + const x = rootElem.x + loc[0]; + const y = rootElem.y + loc[1]; + + return [x, y]; + } + + public coordsInView(lat: number, lng: number) { + const loc = this.projection([lng, lat]); + if (!loc) { + return false + } + + const rootElem = this.mapRoot.nativeElement.getBoundingClientRect(); + const x = rootElem.x + loc[0]; + const y = rootElem.y + loc[1]; + + return x >= rootElem.left && x <= rootElem.right && y >= rootElem.top && y <= rootElem.bottom; + } + +} diff --git a/desktop/angular/src/app/pages/spn/map-renderer/map-style.scss b/desktop/angular/src/app/pages/spn/map-renderer/map-style.scss new file mode 100644 index 00000000..0f319ba7 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map-renderer/map-style.scss @@ -0,0 +1,167 @@ +::ng-deep { + .pin { + opacity: 0; + + &.in-view { + opacity: 1; + } + } +} + +::ng-deep #spn-map { + --map-bg: #111112; + --map-country-active: #424141; + --map-country-inactive: #2a2a2a; + --map-country-border-width: 2px; + --map-country-border-color: #1e1e1e; + --map-country-border-color-selected: #858585; + --map-country-blocked-primary: #858585; + --map-country-blocked-secondary: #402323; + + .overlay { + fill: none; + pointer-events: all; + } + + g { + + circle, + polygon { + fill: #626262; + stroke: #626262; + stroke-width: 1; + stroke-linejoin: round; + transition: all 200ms linear 0s; + } + + circle:hover, + polygon:hover { + fill: theme('colors.yellow.200'); + stroke: theme('colors.yellow.300'); + stroke-width: 2; + } + } + + g[in-use=true] { + circle { + fill: #239215; + stroke: #239215; + transform: scale(1.15) + } + + polygon { + fill: #0376bb; + stroke: #0376bb; + transform: scale(1.15) + } + } + + g[is-exit=true] { + + circle, + polygon { + transform: scale(1.3); + stroke-width: 2; + } + + polygon { + stroke: #039af4; + fill: #0376bb; + } + + circle { + stroke: #30ae20; + fill: #239215; + } + } + + g[is-home=true] circle { + stroke: white; + stroke-width: 4.5; + fill: black; + transform: scale(1); + } + + g[raise=true] { + + circle, + polygon { + fill: theme('colors.yellow.200'); + stroke: theme('colors.yellow.300'); + stroke-width: 2; + transform: scale(1.8); + } + } + + .marker { + cursor: pointer; + fill: #252525; + stroke: rgba(151, 151, 151, 0.8); + transition: all 250ms 0s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + + .marker-label { + fill: white; + } + + path.lane { + stroke: rgba(151, 151, 151, 0.2); + fill: transparent; + + &[in-use=true] { + stroke-width: 2; + stroke: #0376bb; + } + + &[is-live=true] { + stroke-width: 1; + stroke: theme('colors.red.300'); + + &[is-encrypted=true] { + stroke: theme('colors.green.200'); + } + + &:hover { + stroke-width: 3; + } + } + } + + #world-group { + path { + fill: var(--map-country-border-color); + stroke: var(--map-country-border-color); + stroke-width: var(--map-country-border-width); + stroke-linejoin: round; + } + + path[has-nodes=true] { + fill: var(--map-country-inactive); + } + + path[in-use=true] { + fill: var(--map-country-active); + } + + path:hover { + cursor: pointer; + fill: var(--map-country-active); + } + + path.selected { + stroke: var(--map-country-border-color-selected); + } + } +} + +:host-context(.disabled) { + @apply bg-white; + + #world-group { + path { + fill: #000000; + stroke: #111111; + stroke-width: .5px; + } + } +} diff --git a/desktop/angular/src/app/pages/spn/map.service.ts b/desktop/angular/src/app/pages/spn/map.service.ts new file mode 100644 index 00000000..da8041a9 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/map.service.ts @@ -0,0 +1,253 @@ +import { Injectable } from '@angular/core'; +import { AppProfile, GeoCoordinates, IntelEntity, Netquery, Pin, SPNService, UnknownLocation, getPinCoords } from '@safing/portmaster-api'; +import { BehaviorSubject, Observable, combineLatest, debounceTime, interval, of, startWith, switchMap } from 'rxjs'; +import { distinctUntilChanged, filter, map, share } from 'rxjs/operators'; +import { SPNStatus } from './../../../../projects/safing/portmaster-api/src/lib/spn.types'; + +export interface MapPin { + pin: Pin; + // location is set to the geo-coordinates that should be used + // for that pin. + location: GeoCoordinates; + // entity is set to the intel entity that should be used for + // this pin. + entity: IntelEntity; + + // whether the pin is regarded as offline / not available. + isOffline: boolean; + + // whether or not the pin is currently used as an exit node + isExit: boolean; + + // whether or not the pin is used as a transit node + isTransit: boolean; + + // whether or not the pin is currently active. + isActive: boolean; + + // whether or not the pin is used as the entry-node. + isHome: boolean; + + // whether the pin has any known issues + hasIssues: boolean; + + // FIXME: remove me + collapsed?: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class MapService { + /** + * activeSince$ emits the pre-formatted duration since the SPN is active + * it formats the duration as "HH:MM:SS" or null if the SPN is not enabled. + */ + activeSince$: Observable; + + /** Emits the current status of the SPN */ + status$: Observable; + + /** Emits all map pins */ + _pins$ = new BehaviorSubject([]); + + get pins$(): Observable { + return this._pins$.asObservable(); + } + + pinsMap$ = this.pins$ + .pipe( + filter(allPins => !!allPins.length), + map(allPins => { + const lm = new Map(); + allPins.forEach(pin => lm.set(pin.pin.ID, pin)); + + return lm + }), + share(), + ) + + constructor( + private spnService: SPNService, + private netquery: Netquery, + ) { + this.status$ = this.spnService + .status$ + .pipe( + map(status => !!status ? status.Status : 'disabled'), + distinctUntilChanged() + ); + + // setup the activeSince$ observable that emits every second how long the + // SPN has been active. + this.activeSince$ = combineLatest([ + this.spnService.status$, + interval(1000).pipe(startWith(-1)) + ]).pipe( + map(([status]) => !!status.ConnectedSince ? this.formatActiveSinceDate(status.ConnectedSince) : null), + share(), + ); + + let pinMap = new Map(); + let pinResult: MapPin[] = []; + + // create a stream of pin updates from the SPN service if it is enabled. + this.status$ + .pipe( + switchMap(status => { + if (status !== 'disabled') { + return combineLatest([ + this.spnService.watchPins(), + interval(5000) + .pipe( + startWith(-1), + switchMap(() => this.getPinIDsUsedAsExit()) + ) + ]) + } + return of([[], []]); + }), + map(([pins, exitPinIDs]) => { + const exitPins = new Set(exitPinIDs); + const activePins = new Set(); + const transitPins = new Set(); + const seenPinIDs = new Set(); + + let hasChanges = false; + + pins.forEach(pin => pin.Route?.forEach((hop, index) => { + if (index < pin.Route!.length - 1) { + transitPins.add(hop) + } + + activePins.add(hop); + })); + + pins.forEach(pin => { + // Save Pin ID as seen. + seenPinIDs.add(pin.ID); + + const oldPinModel = pinMap.get(pin.ID); + + // Get states of new model. + const isOffline = pin.States.includes('Offline') || !pin.States.includes('Reachable'); + const isHome = pin.HopDistance === 1; + const isTransit = transitPins.has(pin.ID); + + const isExit = exitPins.has(pin.ID); + const isActive = activePins.has(pin.ID); + const hasIssues = pin.States.includes('ConnectivityIssues'); + + const pinHasChanged = !oldPinModel || oldPinModel.pin !== pin || + oldPinModel.isOffline !== isOffline || oldPinModel.isHome !== isHome || oldPinModel.isTransit !== isTransit || + oldPinModel.isExit !== isExit || oldPinModel.isActive !== isActive || oldPinModel.hasIssues !== hasIssues; + + if (pinHasChanged) { + const newPinModel: MapPin = { + pin: pin, + location: getPinCoords(pin) || UnknownLocation, + entity: (pin.EntityV4 || pin.EntityV6)!, + isExit, + isTransit, + isActive, + isOffline, + isHome, + hasIssues, + } + + pinMap.set(pin.ID, newPinModel); + + hasChanges = true; + } + }) + + for (let key of pinMap.keys()) { + if (!seenPinIDs.has(key)) { + // this pin has been removed + pinMap.delete(key) + hasChanges = true; + } + } + + if (hasChanges) { + pinResult = Array.from(pinMap.values()); + } + + return pinResult; + }), + debounceTime(10), + distinctUntilChanged(), + ) + .subscribe(pins => this._pins$.next(pins)) + } + + getExitPinIDsForProfile(profile: AppProfile) { + return this.netquery + .query({ + select: ['exit_node'], + groupBy: ['exit_node'], + query: { + profile: { $eq: `${profile.Source}/${profile.ID}` }, + } + }, 'map-service-get-exit-pin-ids-for-profile') + .pipe(map(result => result.map(row => row.exit_node!))) + } + + getPinIDsWithActiveSession() { + return this.pins$ + .pipe( + map(result => result.filter(pin => pin.pin.SessionActive).map(pin => pin.pin.ID)) + ) + } + + getPinIDsUsedAsExit() { + return this.netquery + .query({ + select: ['exit_node'], + groupBy: ['exit_node'] + }, 'map-service-get-pins-used-as-exit') + .pipe( + map(result => result.map(row => row.exit_node!)) + ) + } + + getPinIDsWithActiveConnections() { + return this.netquery.query({ + select: ['exit_node'], + groupBy: ['exit_node'], + query: { + active: { $eq: true } + } + }, 'map-service-get-pins-with-connections') + .pipe( + map(activeExitNodes => { + const pins = this._pins$.getValue(); + + const pinIDs = new Set(); + const pinLookupMap = new Map(); + + pins.forEach(p => pinLookupMap.set(p.pin.ID, p)) + + activeExitNodes.map(row => { + const pin = pinLookupMap.get(row.exit_node!); + if (!!pin) { + pin.pin.Route?.forEach(hop => { + pinIDs.add(hop) + }) + } + }) + + return Array.from(pinIDs); + }) + ) + } + + private formatActiveSinceDate(date: string): string { + const d = new Date(date); + const diff = Math.floor((new Date().getTime() - d.getTime()) / 1000); + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff - (hours * 3600)) / 60); + const secs = diff - (hours * 3600) - (minutes * 60); + const pad = (d: number) => d < 10 ? `0${d}` : '' + d; + + return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + } +} diff --git a/desktop/angular/src/app/pages/spn/node-icon/index.ts b/desktop/angular/src/app/pages/spn/node-icon/index.ts new file mode 100644 index 00000000..715f271d --- /dev/null +++ b/desktop/angular/src/app/pages/spn/node-icon/index.ts @@ -0,0 +1 @@ +export * from './node-icon'; diff --git a/desktop/angular/src/app/pages/spn/node-icon/node-icon.html b/desktop/angular/src/app/pages/spn/node-icon/node-icon.html new file mode 100644 index 00000000..faf8c684 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/node-icon/node-icon.html @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/desktop/angular/src/app/pages/spn/node-icon/node-icon.scss b/desktop/angular/src/app/pages/spn/node-icon/node-icon.scss new file mode 100644 index 00000000..b62c9bac --- /dev/null +++ b/desktop/angular/src/app/pages/spn/node-icon/node-icon.scss @@ -0,0 +1,38 @@ +svg { + + circle, + polygon { + fill: #626262; + stroke: #626262; + stroke-width: 1; + stroke-linejoin: round; + transition: all 200ms linear 0s; + } + + polygon.active, + polygon.exit { + fill: #0376bb; + stroke: #0376bb; + transform: scale(1.15) + } + + circle.active, + circle.exit { + fill: #239215; + stroke: #239215; + transform: scale(1.15) + } + + circle.exit, + polygon.exit { + stroke-width: 2; + } + + circle.exit { + stroke: #30ae20; + } + + polygon.exit { + stroke: #039af4; + } +} diff --git a/desktop/angular/src/app/pages/spn/node-icon/node-icon.ts b/desktop/angular/src/app/pages/spn/node-icon/node-icon.ts new file mode 100644 index 00000000..daad9a15 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/node-icon/node-icon.ts @@ -0,0 +1,44 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'spn-node-icon', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './node-icon.html', + styleUrls: ['./node-icon.scss'], +}) +export class SpnNodeIconComponent { + @Input() + set bySafing(v: any) { + this._bySafing = coerceBooleanProperty(v); + } + get bySafing() { return this._bySafing } + private _bySafing = false; + + @Input() + set isActive(v: any) { + this._isActive = coerceBooleanProperty(v); + } + get isActive() { return this._isActive } + private _isActive = false; + + @Input() + set isExit(v: any) { + this._isExit = coerceBooleanProperty(v); + } + get isExit() { return this._isExit; } + private _isExit = false; + + get nodeClass() { + if (this._isExit) { + return 'exit'; + } + + if (this.isActive) { + return 'active' + } + + return ''; + } +} diff --git a/desktop/angular/src/app/pages/spn/pin-details/index.ts b/desktop/angular/src/app/pages/spn/pin-details/index.ts new file mode 100644 index 00000000..9a9851da --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-details/index.ts @@ -0,0 +1 @@ +export * from './pin-details'; diff --git a/desktop/angular/src/app/pages/spn/pin-details/pin-details.html b/desktop/angular/src/app/pages/spn/pin-details/pin-details.html new file mode 100644 index 00000000..8e53befc --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-details/pin-details.html @@ -0,0 +1,127 @@ +

+ + {{ pin?.pin?.Name || 'N/A' }} + + + + +

+ + + This SPN Node is run by + + + + {{ pin.pin.VerifiedOwner || 'Community' }} + + +
+ Node is Offline +
+
+ Node has Issues +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ pin.pin.ID }}
Verified Owner +
{{ pin.pin.VerifiedOwner }}
+
First Seen{{ pin.pin.FirstSeen | date:'medium' }}
IPv4 +
+ + + {{ entity.ASOrg }} + ({{ entity.ASN }}) + + + {{ entity.IP || 'N/A' }} + +
+
IPv6 +
+ + + {{ entity.ASOrg }} + ({{ entity.ASN }}) + + + {{ entity.IP || 'N/A' }} + +
+
States +
{{ pin.pin.States.join(", ") }}
+
SessionActive +
{{ pin.pin.SessionActive }}
+
HopDistance +
{{ pin.pin.HopDistance }}
+
Exit Connections +
+
{{ exitConnectionCount }}
+ + + + + + +
+
+
+ + +
+ +
+
+ + + + + +
diff --git a/desktop/angular/src/app/pages/spn/pin-details/pin-details.ts b/desktop/angular/src/app/pages/spn/pin-details/pin-details.ts new file mode 100644 index 00000000..f7e83fae --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-details/pin-details.ts @@ -0,0 +1,100 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnChanges, OnDestroy, OnInit, Optional, SimpleChanges } from '@angular/core'; +import { Netquery } from '@safing/portmaster-api'; +import { SFNG_DIALOG_REF, SfngDialogRef } from '@safing/ui'; +import { Subscription, forkJoin, map, of, switchMap } from 'rxjs'; +import { LaneModel } from '../pin-list/pin-list'; +import { MapPin, MapService } from './../map.service'; + +@Component({ + templateUrl: './pin-details.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PinDetailsComponent implements OnInit, OnChanges, OnDestroy { + private subscription = Subscription.EMPTY; + + @Input() + mapPinID!: string; + + pin: MapPin | null = null; + + /** Holds all pins this pin has a active connection to */ + connectedPins: LaneModel[] = []; + + /** The number of connections that exit at this pin */ + exitConnectionCount: number = 0; + + constructor( + private mapService: MapService, + private netquery: Netquery, + private cdr: ChangeDetectorRef, + @Optional() @Inject(SFNG_DIALOG_REF) public dialogRef?: SfngDialogRef, + ) { } + + ngOnInit(): void { + // if we got opened via a dialog we get the map pin ID from the dialog data. + if (!!this.dialogRef) { + this.mapPinID = this.dialogRef.data; + } + + this.subscription.unsubscribe(); + + this.subscription = this.mapService + .pins$ + .pipe( + map(pins => { + return [pins.find(p => p.pin.ID === this.mapPinID), pins] as [MapPin, MapPin[]]; + }), + switchMap(([pin, allPins]) => forkJoin({ + pin: of(pin), + allPins: of(allPins), + exitConnections: this.netquery.query({ + select: [ + { $count: { field: '*', as: 'totalCount', } }, + ], + query: { + exit_node: pin.pin.ID, + }, + groupBy: ['exit_node'] + }, 'pin-details-get-connections-per-exit-node') + })) + ) + .subscribe((result) => { + this.pin = result.pin || null; + + const lm = new Map(); + result.allPins.forEach(pin => lm.set(pin.pin.ID, pin)) + + const connectedTo = this.pin?.pin.ConnectedTo || {}; + this.connectedPins = Object.keys(connectedTo) + .map(pinID => { + const pin = lm.get(pinID)!; + return { + ...connectedTo[pinID], + mapPin: pin, + } + }); + + if (result.exitConnections.length) { + // we expect only one row to be returned for the above query. + this.exitConnectionCount = result.exitConnections[0].totalCount; + } else { + this.exitConnectionCount = 0; + } + + this.cdr.markForCheck(); + }) + } + + ngOnChanges(changes: SimpleChanges) { + // if we got rendered directly (without a dialog) we need to + // handle updates to the mapPinID input field by re-loading the + // pin details. We do that by simply re-running ngOnInit + if (!!changes['mapPinID']) { + this.ngOnInit() + } + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/pages/spn/pin-list/index.ts b/desktop/angular/src/app/pages/spn/pin-list/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/desktop/angular/src/app/pages/spn/pin-list/pin-list.html b/desktop/angular/src/app/pages/spn/pin-list/pin-list.html new file mode 100644 index 00000000..b21077e4 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-list/pin-list.html @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameOperatorUsed AsLatencyCapacityIPv4IPv6
+ + + {{ pin.pin.Name }} + +
+ + + + + {{ pin.pin.VerifiedOwner || 'Community' }} + +
+
+
+ + + + + + + + + + + + + + + + + +
+
+ {{ val.Latency / 1000 / 1000 | number:'1.0-2' }} ms + + {{ val.Capacity / 1000 / 1000 | number:'1.0-2' }} Mbit/s + {{ pin.pin.EntityV4?.IP || 'N/A' }}{{ pin.pin.EntityV6?.IP || 'N/A' }} + + + +
diff --git a/desktop/angular/src/app/pages/spn/pin-list/pin-list.ts b/desktop/angular/src/app/pages/spn/pin-list/pin-list.ts new file mode 100644 index 00000000..6f3eeedf --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-list/pin-list.ts @@ -0,0 +1,87 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, TrackByFunction } from '@angular/core'; +import { Lane } from '@safing/portmaster-api'; +import { take } from 'rxjs/operators'; +import { MapPin } from '../map.service'; +import { MapService } from './../map.service'; + +export interface LaneModel extends Lane { + mapPin: MapPin; +} + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'spn-pin-list', + templateUrl: './pin-list.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SpnPinListComponent { + @Input() + set allowHover(v: any) { + this._allowHover = coerceBooleanProperty(v); + } + get allowHover() { return this._allowHover } + private _allowHover = true; + + @Input() + set allowClick(v: any) { + this._allowClick = coerceBooleanProperty(v); + } + get allowClick() { return this._allowClick } + private _allowClick = true; + + @Input() + set pins(pins: (string | MapPin | LaneModel)[]) { + this.mapService + .pinsMap$ + .pipe(take(1)) + .subscribe(allPins => { + this.lanes = null; + + this._pins = (pins || []).map(idOrPin => { + if (typeof idOrPin === 'string') { + return allPins.get(idOrPin)!; + } + + if ('mapPin' in idOrPin) { // LaneModel + if (this.lanes === null) { + this.lanes = new Map(); + } + + this.lanes.set(idOrPin.HubID, { + Capacity: idOrPin.Capacity, + Latency: idOrPin.Latency, + }) + + return idOrPin.mapPin; + } + + return idOrPin; // MapPin + }) + + this.cdr.markForCheck(); + }) + } + get pins(): MapPin[] { + return this._pins; + } + private _pins: MapPin[] = []; + + /** If we got LaneModel in @Input() pins than this will contain a map with the capacity/latency */ + lanes: Map> | null = null; + + /** Emits the ID of the pin that got hovered, null if the mouse left a pin */ + @Output() + pinHover = new EventEmitter(); + + @Output() + pinClick = new EventEmitter(); + + /** @private - A {@link TrackByFunction} for all pins available in this country */ + trackPin: TrackByFunction = (_: number, pin: MapPin) => pin.pin.ID; + + constructor( + private mapService: MapService, + private cdr: ChangeDetectorRef + ) { } +} diff --git a/desktop/angular/src/app/pages/spn/pin-overlay/index.ts b/desktop/angular/src/app/pages/spn/pin-overlay/index.ts new file mode 100644 index 00000000..620c76d3 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-overlay/index.ts @@ -0,0 +1 @@ +export * from './pin-overlay'; diff --git a/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.html b/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.html new file mode 100644 index 00000000..4bcd2f4c --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.html @@ -0,0 +1,117 @@ +
+
+ + Show Details + Show exit connections + Copy Node ID + + + + + {{ mapPin.pin.Name }} + + + + + + + + + + + + + + + + + + +
+
+ IPv4 + {{ mapPin.pin.EntityV4?.IP || 'N/A' }} +
+
+ IPv6 + {{ mapPin.pin.EntityV6?.IP || 'N/A' }} +
+
+ Run By + + + + + + + {{ mapPin.pin.VerifiedOwner || 'Community' }} + +
+
+ Used As + +
+ + + + + + + Home Node + + + + + + + + + + Exit Node + + + + + + + + + + Transit Node + + + + +
+
+
+ + + + + + + + + diff --git a/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.scss b/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.scss new file mode 100644 index 00000000..68c4ea1f --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.scss @@ -0,0 +1,4 @@ +:host { + min-width: 220px; + display: block; +} diff --git a/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.ts b/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.ts new file mode 100644 index 00000000..00122703 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.ts @@ -0,0 +1,190 @@ +import { AnimationEvent, animate, keyframes, style, transition, trigger } from '@angular/animations'; +import { CdkDrag, CdkDragHandle, CdkDragRelease } from '@angular/cdk/drag-drop'; +import { Overlay, OverlayRef, PositionStrategy } from '@angular/cdk/overlay'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostListener, Inject, Input, OnInit, Output, ViewChild, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { SfngDialogService } from '@safing/ui'; +import { PinDetailsComponent } from '../pin-details'; +import { MapOverlay, Path } from '../spn-page'; +import { ActionIndicatorService } from './../../../shared/action-indicator/action-indicator.service'; +import { MapPin } from './../map.service'; +import { OVERLAY_REF } from './../utils'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; + +export interface PinOverlayHoverEvent { + type: 'enter' | 'leave'; + pinID: string; +} + +@Component({ + templateUrl: './pin-overlay.html', + styleUrls: [ + './pin-overlay.scss' + ], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('moveIn', [ + transition(':enter', [ + style({ transform: 'scale(0)', transformOrigin: 'top left' }), + animate('200ms {{ delay }}ms cubic-bezier(0, 0, 0.2, 1)', + keyframes([ + style({ transform: 'scaleX(1) scaleY(0.1)', transformOrigin: 'top left', offset: 0.3 }), + style({ transform: 'scaleX(1) scaleY(1)', transformOrigin: 'top left', offset: 0.8 }), + ]) + ) + ], { params: { delay: "0" } }), + transition(':leave', [ + style({ transform: 'scale(1)', opacity: 1, transformOrigin: 'top left' }), + animate('500ms cubic-bezier(0, 0, 0.2, 1)', + keyframes([ + style({ transform: 'scaleX(1) scaleY(0.1)', opacity: 0.5, transformOrigin: 'top left', offset: 0.3 }), + style({ transform: 'scaleX(0) scaleY(0)', opacity: 0, transformOrigin: 'top left', offset: 0.8 }), + ]) + ) + ]) + ]) + ] +}) +export class PinOverlayComponent implements OnInit { + private readonly integration = inject(INTEGRATION_SERVICE); + + @Input() + mapPin!: MapPin; + + @Input() + routeHome?: Path; + + @Input() + additionalPaths?: Path[] = []; + + @Input() + delay: number = 0; + + @Output() + afterDispose = new EventEmitter(); + + @Output() + overlayHover = new EventEmitter(); + + @ViewChild(CdkDrag) + dragContainer!: CdkDrag; + + @ViewChild(CdkDragHandle) + dragHandle!: CdkDragHandle; + + showContent = false; + + /** Indicates whether or not the pin overlay has been moved by the user */ + hasBeenMoved = false; + + private oldPositionStrategy?: PositionStrategy; + + @HostListener('mouseenter') + onHostElementMouseEnter(event: MouseEvent) { + this.overlayHover.next({ + type: 'enter', + pinID: this.mapPin.pin.ID + }) + + this.containerClass = ''; + } + + @HostListener('mouseleave') + onHostElementMouseLeave(event: MouseEvent) { + this.overlayHover.next({ + type: 'leave', + pinID: this.mapPin.pin.ID + }) + + this.containerClass = 'bg-opacity-90' + } + + /** on double-click, restore the old pin overlay position (before being initialy dragged by the user) */ + onDragDblClick() { + if (!!this.oldPositionStrategy) { + this.overlayRef.updatePositionStrategy(this.oldPositionStrategy); + this.overlayRef.updatePosition(); + this.hasBeenMoved = false; + } + } + + onDragStart() { + this.containerClass = 'outline' + } + + openPinDetails() { + this.dialog.create(PinDetailsComponent, { + data: this.mapPin.pin.ID, + autoclose: true, + backdrop: false, + dragable: true, + }) + } + + onDragRelease(event: CdkDragRelease) { + if (!this.dragContainer || !this.overlayRef.hostElement || !this.overlayRef.hostElement.parentElement) { + return; + } + + const bbox = this.dragContainer.element.nativeElement.getBoundingClientRect(); + const parent = this.overlayRef.hostElement.parentElement!.getBoundingClientRect(); + + if (!this.oldPositionStrategy) { + this.oldPositionStrategy = this.overlayRef.getConfig().positionStrategy; + } + + this.containerClass = ''; + + this.dragContainer.reset() + + this.overlayRef.updatePositionStrategy( + this.overlay.position() + .global() + .top((bbox.top - parent.top) + 'px') + .left((bbox.left - parent.left) + 'px') + ); + + this.hasBeenMoved = true; + } + + onAnimationComplete(event: AnimationEvent) { + if (event.toState === 'void') { + this.afterDispose.next(this.mapPin.pin.ID) + this.overlayRef.dispose(); + } + } + + containerClass = ''; + + constructor( + @Inject(OVERLAY_REF) public readonly overlayRef: OverlayRef, + @Inject(MapOverlay) public overlay: Overlay, + private dialog: SfngDialogService, + private actionIndicator: ActionIndicatorService, + private router: Router, + private cdr: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.showContent = true; + this.cdr.markForCheck(); + } + + disposeOverlay() { + this.showContent = false; + this.cdr.markForCheck(); + } + + showExitConnections() { + this.router.navigate(['/monitor'], { + queryParams: { + q: 'exit_node:' + this.mapPin.pin.ID + } + }) + } + + async copyNodeID() { + await this.integration.writeToClipboard(this.mapPin?.pin.ID) + this.actionIndicator.success("Copied to Clipboard") + } +} diff --git a/desktop/angular/src/app/pages/spn/pin-route/index.ts b/desktop/angular/src/app/pages/spn/pin-route/index.ts new file mode 100644 index 00000000..f97ea758 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-route/index.ts @@ -0,0 +1 @@ +export * from './pin-route'; diff --git a/desktop/angular/src/app/pages/spn/pin-route/pin-route.html b/desktop/angular/src/app/pages/spn/pin-route/pin-route.html new file mode 100644 index 00000000..1927c5cd --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-route/pin-route.html @@ -0,0 +1,53 @@ + +
+ +
+
    +
  • + + + + + Your Device + +
  • + +
  • + + + + + + {{ node.entity.Country || 'No Location' }} + {{ node.entity.IP || '' + }} + Home + Exit +
    {{ node.pin.Name }} + + + by + + {{ node.pin.VerifiedOwner || 'Community' }} + +
    + +
    AS{{ node.entity.ASN }} - {{ node.entity.ASOrg || + 'AS Organization not in DB' + }}
    + +
    {{ node.pin.ID }}
    +
  • + +
  • + + + + + + Destination + + +
  • +
+
diff --git a/desktop/angular/src/app/pages/spn/pin-route/pin-route.scss b/desktop/angular/src/app/pages/spn/pin-route/pin-route.scss new file mode 100644 index 00000000..2c66a8ee --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-route/pin-route.scss @@ -0,0 +1,67 @@ +.tunnel-path { + position: relative; + + .line { + position: absolute; + top: 10px; + bottom: 10px; + left: 8px; + width: 1px; + background-color: rgba(255, 255, 255, 0.1); + } + + .node-tag { + border-radius: 1px solid rgba(255, 255, 255, 0.2); + background-color: rgba(255, 255, 255, 0.1); + padding: 2px; + font-size: 85%; + border-radius: 2px; + transform: scale(0.85); + display: inline-block; + } + + ul { + position: relative; + padding-left: 20px; + + li:not(:last-of-type) { + padding-bottom: 0.35rem; + } + + .ip { + margin-left: 0.35rem; + } + + .hop-icon { + display: inline-block; + margin-left: -17px; + margin-right: 4px; + font-weight: 400; + + &.country { + margin-left: -20px; + } + } + + .hop-title { + margin-right: 2px; + } + + .country { + display: inline-block; + margin-left: -20px; + margin-right: 4px; + + &.unknown { + height: 14px; + width: 16px; + position: relative; + top: 3px; + border: 1px solid rgba(0, 0, 0, 0.25); + opacity: 0.5; + border-radius: 3px; + @apply bg-buttons-icon; + } + } + } +} diff --git a/desktop/angular/src/app/pages/spn/pin-route/pin-route.ts b/desktop/angular/src/app/pages/spn/pin-route/pin-route.ts new file mode 100644 index 00000000..d862619d --- /dev/null +++ b/desktop/angular/src/app/pages/spn/pin-route/pin-route.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input } from "@angular/core"; +import { TunnelNode } from "@safing/portmaster-api"; +import { take } from 'rxjs'; +import { MapPin, MapService } from './../map.service'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'sfng-spn-pin-route', + templateUrl: './pin-route.html', + styleUrls: ['./pin-route.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SpnPinRouteComponent { + @Input() + set route(path: (string | MapPin | TunnelNode)[] | null) { + this.mapService + .pinsMap$ + .pipe( + take(1), + ) + .subscribe(lm => { + this._route = (path || []).map(idOrPin => { + if (typeof idOrPin === 'string') { + return lm.get(idOrPin)!; + } + + if ('ID' in idOrPin) { // TunnelNode + return lm.get(idOrPin.ID)! + } + + return idOrPin; + }); + + this.cdr.markForCheck(); + }) + } + get route(): MapPin[] { + return this._route + } + private _route: MapPin[] = []; + + constructor( + private mapService: MapService, + private cdr: ChangeDetectorRef, + ) { } +} diff --git a/desktop/angular/src/app/pages/spn/spn-feature-carousel/index.ts b/desktop/angular/src/app/pages/spn/spn-feature-carousel/index.ts new file mode 100644 index 00000000..d07cc9e1 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-feature-carousel/index.ts @@ -0,0 +1 @@ +export * from './spn-feature-carousel'; diff --git a/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.html b/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.html new file mode 100644 index 00000000..b73683f4 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.html @@ -0,0 +1,274 @@ +
+ + + + + + +
+
+

Get + Multiple Identities for Each App

+ + Automatically get a vast amount of identities (IP addresses). The SPN calculates an individual path for + every + connection through the privacy network. Spread your connections across the globe, without any effort. + +
+ +
+
+ + + + +
+
+

Easily Adjust Your Privacy

+ + SPN just works and does the heavy lifting for you. But of course you can easily configure the settings, so + it fits your needs: Exclude certain apps and domains from the SPN. Or never exit in specific countries. And + so much more... + +
+ +
+
+ + +
+
+

Built from Scratch, for Your Privacy

+ + SPN is built from the ground up. Privacy is cooked right into it. Inspired by Tor, it comes with onion + routing and state of the art encryption. Fully open source so all our claims can be validated. + +
+ +
+
+ + +
+
+

Bye Bye, VPNs

+ + VPN technology was NOT built for user privacy, but for company security. Because of that, you can only trust + a VPN provider's policy - and many have been caught abusing user data. Honestly, the best way forward: just + stop paying for outdated technology. + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Most VPNs +
+ Read Comparison Blog +
+
SPNTor
Multiple Identities (simultaneous) + + + + + +
Individual Apps Settings + + + + + +
Easy Setup + + + + Browser Only
Availabilty +
+ + + + +
+
+
+ + +
+
+
+ + + + +
+
Open Source + + + + + +
Built for Privacy + + + + + +
+
+
+
+ + + + + +
+ +
+ +
diff --git a/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.scss b/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.scss new file mode 100644 index 00000000..7ffa92a2 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.scss @@ -0,0 +1,62 @@ +:host { + @apply flex flex-col gap-2 justify-center items-center relative; +} + +section { + @apply flex flex-row items-start gap-4 justify-evenly text-background; + + &.reverse { + @apply flex-row-reverse + } + + &>div { + @apply flex flex-col w-1/3 gap-6; + + span { + @apply text-base break-normal text-background text-opacity-80; + } + + h1, + h1>span { + @apply text-2xl font-semibold break-normal md:text-3xl lg:text-4xl xl:text-5xl text-background; + + } + + h1>span { + &.text-blue { + color: theme('colors.blue.DEFAULT') !important; + } + } + } + + img { + position: relative; + max-width: 50%; + } + + table { + @apply mb-12; + + th { + @apply text-base; + } + + td { + @apply text-center p-2 leading-6; + } + + tr>td:first-of-type { + @apply text-left p-2 font-semibold text-base whitespace-nowrap; + } + } +} + +::ng-deep { + spn-feature-carousel { + sfng-tab-outlet { + &>div { + overflow: visible !important; + } + } + } +} diff --git a/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.ts b/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.ts new file mode 100644 index 00000000..e68edbb3 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.ts @@ -0,0 +1,83 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, OnDestroy, QueryList, ViewChild, ViewChildren } from "@angular/core"; +import { SfngTabComponent, SfngTabGroupComponent } from '@safing/ui'; +import { filter, interval, startWith, Subscription } from 'rxjs'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'spn-feature-carousel', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './spn-feature-carousel.html', + styleUrls: [ + './spn-feature-carousel.scss' + ] +}) +export class SPNFeatureCarouselComponent implements AfterViewInit, OnDestroy { + private sub: Subscription = Subscription.EMPTY; + + pause = false; + currentIndex = -1; + + @HostListener('mouseenter') + onMouseEnter() { + this.pause = true + } + + @HostListener('mouseleave') + onMouseLeave() { + this.pause = false; + } + + /** A list of all carousel templates */ + @ViewChildren(SfngTabComponent) + carousel!: QueryList; + + @ViewChild(SfngTabGroupComponent) + tabGroup!: SfngTabGroupComponent; + + constructor( + private cdr: ChangeDetectorRef + ) { } + + ngAfterViewInit(): void { + this.sub = interval(5000) + .pipe( + startWith(-1), + filter(() => !this.pause), + ) + .subscribe(() => { + this.openTab(this.currentIndex + 1, 'left') + }) + } + + ngOnDestroy(): void { + this.sub.unsubscribe() + } + + openTab(idx: number, direction?: 'left' | 'right') { + // force animation to circle if we go before the first + // or after the last one. + if (idx < 0) { + idx = this.carousel.length - 1; + direction = 'right' + } + if (idx >= this.carousel.length) { + direction = 'left' + } + + this.currentIndex = idx % this.carousel.length; + this.tabGroup.activateTab(this.currentIndex, direction)!; + this.cdr.markForCheck(); + } + + showNext() { + this.sub.unsubscribe() + + this.openTab(this.currentIndex + 1) + } + + showPrev() { + this.sub.unsubscribe() + + this.openTab(this.currentIndex - 1) + } +} diff --git a/desktop/angular/src/app/pages/spn/spn-page.html b/desktop/angular/src/app/pages/spn/spn-page.html new file mode 100644 index 00000000..28a61450 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-page.html @@ -0,0 +1,102 @@ + +
+
+ +
+ + Loading data, please wait ... +
+
+
+ +
+
+ + + + +
+ + + + + + Pricing + +
+
+
+
+ + +
+ + +
+ +
+ + + + + + + +
+ + + + Pro Tip: + +
+ + +
+
+ + + + Hold +
CTRL
key and click a node on the map to immediately open the node details dialog. +
+ + + Hold +
SHIFT
key to open more than one node overlay when clicking the node icon. +
+ + + To keep node overlays open move them using + + + . Double click to revert the overlay position on the map. + + + + Click on a country to get more information about all nodes in that country and a list of Apps that use nodes in the + country as an identity. + +
diff --git a/desktop/angular/src/app/pages/spn/spn-page.scss b/desktop/angular/src/app/pages/spn/spn-page.scss new file mode 100644 index 00000000..40441ef4 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-page.scss @@ -0,0 +1,143 @@ +:host { + @apply flex flex-row w-full h-full justify-items-stretch items-stretch relative; +} + +.text-info-red { + color: theme("colors.info.red"); +} + +.network-status-dialog { + width: 50vw; + height: 50vh; + min-height: 300px; + min-width: 400px; + padding: 12px; + overflow: auto; + display: flex; + flex-direction: column; + + .issue { + flex-grow: 1; + } + + .issue-list { + width: 100% !important; + flex-grow: 1; + + ul { + overflow: auto; + } + } + + .issue.expanded { + background-color: var(--button-light) !important; + } + + .body { + background-color: var(--cards-primary) !important; + } +} + +.connect-button { + + &.spn-connected { + @apply bg-info-blue; + } + + &.spn-connecting { + @apply bg-info-blue; + } + + &.spn-failed { + @apply bg-info-red; + } + + &:hover { + @apply bg-info-blue opacity-75; + } +} + +.table { + @apply w-full font-normal; + + &>div { + @apply text-xs border-buttons-dark flex flex-row justify-between py-1; + + &:not(:last-child) { + @apply border-b; + } + + span:first-child { + @apply text-tertiary; + } + + span:last-child { + @apply text-primary; + } + } +} + + +table tr:nth-child(odd) { + background: none; +} + + +.tunnel-path { + position: relative; + + .line { + position: absolute; + top: 10px; + bottom: 10px; + left: 8px; + width: 1px; + background-color: rgba(255, 255, 255, 0.1); + } + + + ul { + position: relative; + padding-left: 20px; + + li:not(:last-of-type) { + padding-bottom: 0.35rem; + } + + .ip { + margin-left: 0.35rem; + } + + .hop-icon { + display: inline-block; + margin-left: -17px; + margin-right: 4px; + font-weight: 400; + + &.country { + margin-left: -20px; + } + } + + .hop-title { + margin-right: 2px; + } + + .country { + display: inline-block; + margin-left: -20px; + margin-right: 4px; + + &.unknown { + height: 14px; + width: 16px; + position: relative; + top: 3px; + border: 1px solid rgba(0, 0, 0, 0.25); + opacity: 0.5; + border-radius: 3px; + @apply bg-buttons-icon; + } + } + } +} diff --git a/desktop/angular/src/app/pages/spn/spn-page.ts b/desktop/angular/src/app/pages/spn/spn-page.ts new file mode 100644 index 00000000..992dbe19 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn-page.ts @@ -0,0 +1,1012 @@ +import { coerceElement } from "@angular/cdk/coercion"; +import { Overlay, OverlayContainer } from "@angular/cdk/overlay"; +import { ComponentPortal } from '@angular/cdk/portal'; +import { HttpClient } from '@angular/common/http'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, DestroyRef, ElementRef, Inject, Injectable, InjectionToken, Injector, OnDestroy, OnInit, QueryList, TemplateRef, ViewChild, ViewChildren, forwardRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, ParamMap, Router } from "@angular/router"; +import { AppProfile, ConfigService, Connection, ExpertiseLevel, FeatureID, Netquery, PORTMASTER_HTTP_API_ENDPOINT, PortapiService, SPNService, SPNStatus, UserProfile } from "@safing/portmaster-api"; +import { SfngDialogService } from "@safing/ui"; +import { Line as D3Line, Selection, interpolateString, line, select } from 'd3'; +import { BehaviorSubject, Observable, Subscription, combineLatest, interval, of } from "rxjs"; +import { catchError, debounceTime, map, mergeMap, share, startWith, switchMap, take, takeUntil, withLatestFrom } from "rxjs/operators"; +import { fadeInAnimation, fadeInListAnimation, fadeOutAnimation } from "src/app/shared/animations"; +import { ExpertiseService } from "src/app/shared/expertise/expertise.service"; +import { SPNAccountDetailsComponent } from "src/app/shared/spn-account-details"; +import { CountryDetailsComponent } from "./country-details"; +import { CountryEvent, MAP_HANDLER, MapRef, MapRendererComponent } from "./map-renderer/map-renderer"; +import { MapPin, MapService } from "./map.service"; +import { PinDetailsComponent } from "./pin-details"; +import { PinOverlayComponent } from "./pin-overlay"; +import { OVERLAY_REF } from './utils'; + +export const MapOverlay = new InjectionToken('MAP_OVERLAY') + +export type PinGroup = Selection; +export type LaneGroup = Selection; + +export interface Path { + id: string; + points: (MapPin | [number, number])[]; + attributes?: { + [key: string]: string; + } +} + +export interface PinEvent { + event?: MouseEvent; + mapPin: MapPin; +} + + +/** + * A custom class that implements the OverlayContainer interface of CDK. This + * is used so we can configure a custom container element that will hold all overlays created + * by the map component. This way the overlays will be bound to the map container and not overflow + * the sidebar or other overlays that are created by the "root" app. + */ +@Injectable() +class MapOverlayContainer { + private _overlayContainer?: HTMLElement; + + setOverlayContainer(element: ElementRef | HTMLElement) { + this._overlayContainer = coerceElement(element); + } + + getContainerElement(): HTMLElement { + if (!this._overlayContainer) { + throw new Error("Overlay container element not initialized. Call setOverlayContainer first.") + } + + return this._overlayContainer; + } +} + +@Component({ + templateUrl: './spn-page.html', + styleUrls: ['./spn-page.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + MapOverlayContainer, + { provide: MapOverlay, useClass: Overlay }, + { provide: OverlayContainer, useExisting: MapOverlayContainer }, + { provide: MAP_HANDLER, useExisting: forwardRef(() => SpnPageComponent), multi: true } + ], + animations: [ + fadeInListAnimation, + fadeInAnimation, + fadeOutAnimation + ] +}) +export class SpnPageComponent implements OnInit, OnDestroy, AfterViewInit { + private destroyRef = inject(DestroyRef); + + private countryDebounceTimer: any | null = null; + + /** a list of opened country details. required to close them on destry */ + private openedCountryDetails: CountryDetailsComponent[] = []; + + readonly featureID = FeatureID.SPN; + + paths: Path[] = []; + + @ViewChild('overlayContainer', { static: true, read: ElementRef }) + overlayContainer!: ElementRef; + + @ViewChild(MapRendererComponent, { static: true }) + mapRenderer!: MapRendererComponent; + + @ViewChild('accountDetails', { read: TemplateRef, static: true }) + accountDetails: TemplateRef | null = null; + + /** A list of pro-tip templates in our view */ + @ViewChildren('proTip', { read: TemplateRef }) + proTipTemplates!: QueryList>; + + /** The selected pro-tip template */ + proTipTemplate: TemplateRef | null = null; + + /** currentUser holds the current SPN user profile if any */ + currentUser: UserProfile | null = null; + + /** An observable that emits all active processes. */ + activeProfiles$: Observable; + + /** Whether or not we are still waiting for all data in order to satisfy a "show process/pin" request by query-params */ + loading = true; + + /** a list of currently selected pins */ + selectedPins: PinOverlayComponent[] = []; + + /** the currently hovered country, if any */ + hoveredCountry: { + countryName: string; + countryCode: string; + } | null = null; + + liveMode = false; + liveModePaths: Path[] = []; + + private liveModeSubscription = Subscription.EMPTY; + + /** + * spnStatusTranslation translates the spn status to the text that is displayed + * at the view + */ + readonly spnStatusTranslation: Readonly> = { + connected: 'Connected', + connecting: 'Connecting', + disabled: 'Disabled', + failed: 'Failure' + } + + + private mapRef: MapRef | null = null; + private lineFunc: D3Line<(MapPin | [number, number])> | null = null; + private highlightedPins = new Set(); + + registerMap(ref: MapRef) { + this.mapRef = ref; + + ref.onMapReady(() => { + // we want to have straight lines between our hubs so we use a custom + // path function that updates x and y coordinates based on the mercator projection + // without, points will no be at the correct geo-coordinates. + this.lineFunc = line() + .x(d => { + if (Array.isArray(d)) { + return this.mapRef!.projection([d[0], d[1]])![0]; + } + return this.mapRef!.projection([d.location.Longitude, d.location.Latitude])![0]; + }) + .y(d => { + if (Array.isArray(d)) { + return this.mapRef!.projection([d[0], d[1]])![1]; + } + return this.mapRef!.projection([d.location.Longitude, d.location.Latitude])![1]; + }) + + this.mapRef!.root.append('g').attr('id', 'line-group') + this.mapRef!.root.append('g').attr('id', 'pin-group') + + if (this.mapService._pins$.getValue().length > 0) { + this.renderPins(this.mapService._pins$.getValue()) + } + }) + + ref.onCountryClick(event => this.onCountryClick(event)) + ref.onCountryHover(event => this.onCountryHover(event)) + ref.onZoomPan(() => this.onZoomAndPan()) + } + + unregisterMap(ref: MapRef) { + this.mapRef = null; + this.lineFunc = null; + } + + constructor( + private configService: ConfigService, + private spnService: SPNService, + private netquery: Netquery, + private expertiseService: ExpertiseService, + private router: Router, + private route: ActivatedRoute, + private portapi: PortapiService, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string, + private http: HttpClient, + public mapService: MapService, + @Inject(MapOverlay) private mapOverlay: Overlay, + private dialog: SfngDialogService, + private overlayContainerService: MapOverlayContainer, + private cdr: ChangeDetectorRef, + private injector: Injector, + ) { + this.activeProfiles$ = interval(5000) + .pipe( + startWith(-1), + switchMap(() => this.netquery.getActiveProfiles()), + share({ connector: () => new BehaviorSubject([]) }) + ) + } + + ngAfterViewInit() { + // configure our custom overlay container + this.overlayContainerService.setOverlayContainer(this.overlayContainer); + + // Select a random "Pro-Tip" template and run change detection + this.proTipTemplate = this.proTipTemplates.get(Math.floor(Math.random() * this.proTipTemplates.length)) || null; + this.cdr.detectChanges(); + } + + openAccountDetails() { + this.dialog.create(SPNAccountDetailsComponent, { + autoclose: true, + backdrop: 'light' + }) + } + + ngOnInit() { + this.spnService + .profile$ + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError(() => of(null)) + ) + .subscribe((user: UserProfile | null) => { + if (user?.state !== '') { + this.currentUser = user || null; + } else { + this.currentUser = null; + } + + this.cdr.markForCheck(); + }) + + let previousQueryMap: ParamMap | null = null; + + combineLatest([ + this.route.queryParamMap, + this.mapService.pins$, + this.activeProfiles$, + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe(([params, pins, profiles]) => { + if (params !== previousQueryMap) { + const app = params.get("app") + if (!!app) { + const profile = profiles.find(p => `${p.Source}/${p.ID}` === app); + if (!!profile) { + const pinID = params.get("pin") + const pin = pins.find(p => p.pin.ID === pinID); + + this.selectGroup(profile, pin) + } + } + + previousQueryMap = params; + } + + this.renderPins(pins); + + // we're done with everything now. + this.loading = false; + }) + + } + + toggleLiveMode(enabled: boolean) { + this.liveMode = enabled; + + if (!enabled) { + this.liveModeSubscription.unsubscribe(); + this.liveModePaths = []; + this.updatePaths([]); + this.cdr.markForCheck(); + + return; + } + + this.liveModeSubscription = this.portapi.watchAll("network:tree") + .pipe( + withLatestFrom(this.mapService.pinsMap$), + takeUntilDestroyed(this.destroyRef), + debounceTime(100), + ) + .subscribe(([connections, mapPins]) => { + connections = connections.filter(conn => conn.Ended === 0 && !!conn.TunnelContext); + + this.liveModePaths = connections.map(conn => { + const points: (MapPin | [number, number])[] = conn.TunnelContext!.Path.map(hop => mapPins.get(hop.ID)!) + + if (!!conn.Entity.Coordinates) { + points.push([conn.Entity.Coordinates.Longitude, conn.Entity.Coordinates.Latitude]) + } + + return { + id: conn.Entity.Domain || conn.ID, + points: points, + attributes: { + 'is-live': 'true', + 'is-encrypted': `${conn.Encrypted}` + } + } + }) + + this.updatePaths([]) + this.cdr.markForCheck(); + }) + } + + /** + * Toggle the spn/enable setting. This does NOT update the view as that + * will happen as soon as we get an update from the db qsub. + * + * @private - template only + */ + toggleSPN() { + this.configService.get('spn/enable') + .pipe( + map(setting => setting.Value ?? setting.DefaultValue), + mergeMap(active => this.configService.save('spn/enable', !active)) + ) + .subscribe() + } + + /** + * Select one or more pins by ID. If shift key is hold then all currently + * selected pin overlays will be cleared before selecting the new ones. + */ + private selectPins(event: MouseEvent | undefined, pinIDs: Observable) { + combineLatest([ + this.mapService.pins$, + pinIDs, + ]) + .pipe(take(1)) + .subscribe(([allPins, pinIDs]) => { + if (event?.shiftKey !== true) { + this.selectedPins + .filter(overlay => !overlay.hasBeenMoved) + .forEach(selected => selected.disposeOverlay()) + } + + pinIDs + .filter(id => !this.selectedPins.find(selectedPin => selectedPin.mapPin.pin.ID === id)) + .map(id => allPins.find(pin => pin.pin.ID === id)) + .filter(mapPin => !!mapPin) + .forEach(mapPin => this.onPinClick({ + mapPin: mapPin!, + })); + }) + } + + /** + * Select all pins that are used for transit. + * + * @private - template only + */ + selectTransitNodes(event: MouseEvent) { + this.selectPins(event, this.mapService.getPinIDsWithActiveSession()) + } + + /** + * Select all pins that are used as an exit hub. + * + * @private - template only + */ + selectExitNodes(event: MouseEvent) { + this.selectPins(event, this.mapService.getPinIDsUsedAsExit()) + } + + /** + * Select all pins that currently host alive connections. + * + * @private - template only + */ + selectNodesWithAliveConnections(event: MouseEvent) { + this.selectPins(event, this.mapService.getPinIDsWithActiveConnections()) + } + + navigateToMonitor(process: AppProfile) { + this.router.navigate(['/app', process.Source, process.ID]) + } + + ngOnDestroy() { + this.openedCountryDetails.forEach(cmp => cmp.dialogRef!.close()); + } + + onZoomAndPan() { + this.updateOverlayPositions(); + + if (this.mapRef) { + this.mapRef.root + .select('#lines-group') + .selectAll('path') + .attr('d', d => this.lineFunc!(d.points)) + + this.mapRef.root + .select("#pin-group") + .selectAll('g') + .attr('transform', d => `translate(${this.mapRef!.projection([d.location.Longitude, d.location.Latitude])})`) + } + + this.cdr.markForCheck(); + } + + private createPinOverlay(pinEvent: PinEvent, lm: Map): PinOverlayComponent { + const paths = this.getRouteHome(pinEvent.mapPin, lm, false) + const overlayBoundingRect = this.overlayContainer.nativeElement.getBoundingClientRect(); + const target = pinEvent.event?.target || this.getPinElem(pinEvent.mapPin.pin.ID)?.children[0]; + let delay = 0; + if (paths.length > 0) { + delay = paths[0].points.length * MapRendererComponent.LineAnimationDuration; + } + + const overlayRef = this.mapOverlay.create({ + positionStrategy: this.mapOverlay.position() + .flexibleConnectedTo(new ElementRef(target)) + .withDefaultOffsetY(-overlayBoundingRect.y - 10) + .withDefaultOffsetX(-overlayBoundingRect.x + 20) + .withPositions([ + { + overlayX: 'start', + overlayY: 'top', + originX: 'start', + originY: 'top' + } + ]), + scrollStrategy: this.mapOverlay.scrollStrategies.reposition(), + }) + + const injector = Injector.create({ + providers: [ + { + provide: OVERLAY_REF, + useValue: overlayRef, + } + ], + parent: this.injector + }) + + + const pinOverlay = overlayRef.attach( + new ComponentPortal(PinOverlayComponent, undefined, injector) + ).instance; + + pinOverlay.delay = delay; + pinOverlay.mapPin = pinEvent.mapPin; + if (paths.length > 0) { + pinOverlay.routeHome = { + ...(paths[0]), + } + pinOverlay.additionalPaths = paths.slice(1); + } + + return pinOverlay; + } + + + private openPinDetails(id: string) { + this.dialog.create(PinDetailsComponent, { + data: id, + backdrop: false, + autoclose: true, + dragable: true, + }) + } + + private openCountryDetails(event: CountryEvent) { + // abort if we already have the country details open. + if (this.openedCountryDetails.find(cmp => cmp.countryCode === event.countryCode)) { + return; + } + + const ref = this.dialog.create(CountryDetailsComponent, { + data: { + name: event.countryName, + code: event.countryCode, + }, + autoclose: false, + dragable: true, + backdrop: false, + }) + const component = (ref.contentRef() as ComponentRef).instance; + + // used to track whether we highlighted a map pin + let hasPinHighlightActive = false; + + combineLatest([ + component.pinHover, + this.mapService.pins$, + ]) + .pipe( + takeUntil(ref.onClose), + ) + .subscribe(([hovered, pins]) => { + hasPinHighlightActive = hovered !== null; + + if (hovered !== null) { + this.onPinHover({ + mapPin: pins.find(p => p.pin.ID === hovered)!, + }) + this.highlightPin(hovered, true) + } else { + this.onPinHover(null); + this.clearPinHighlights(); + } + + + this.cdr.markForCheck(); + }) + + ref.onClose + .subscribe(() => { + if (hasPinHighlightActive) { + this.clearPinHighlights(); + } + + const index = this.openedCountryDetails.findIndex(cmp => cmp === component); + if (index >= 0) { + this.openedCountryDetails.splice(index, 1); + } + }) + + this.openedCountryDetails.push(component); + } + + private updateOverlayPositions() { + this.mapService.pinsMap$ + .pipe(take(1)) + .subscribe(allPins => { + this.selectedPins.forEach(pin => { + const pinObj = allPins.get(pin.mapPin.pin.ID); + if (!pinObj) { + return; + } + + pin.overlayRef.updatePosition(); + }) + }) + } + + onCountryClick(countryEvent: CountryEvent) { + this.openCountryDetails(countryEvent); + } + + onCountryHover(countryEvent: CountryEvent | null) { + if (this.countryDebounceTimer !== null) { + clearTimeout(this.countryDebounceTimer); + } + + if (!!countryEvent) { + this.hoveredCountry = { + countryCode: countryEvent.countryCode, + countryName: countryEvent.countryName, + } + this.cdr.markForCheck(); + + return; + } + + this.countryDebounceTimer = setTimeout(() => { + this.hoveredCountry = null; + this.countryDebounceTimer = null; + this.cdr.markForCheck(); + }, 200) + } + + onPinClick(pinEvent: PinEvent) { + // if the control key hold when clicking a map pin, we immediately open the + // pin details instead of the overlay. + if (pinEvent.event?.ctrlKey) { + this.openPinDetails(pinEvent.mapPin.pin.ID); + } + + const overlay = this.selectedPins.find(por => por.mapPin.pin.ID === pinEvent.mapPin.pin.ID); + if (!!overlay) { + overlay.disposeOverlay() + return; + } + + // if shiftKey was not pressed during the pinClick we dispose all active overlays that have not been + // moved by the user + if (!pinEvent.event?.shiftKey) { + this.selectedPins + .filter(overlay => !overlay.hasBeenMoved) + .forEach(selected => selected.disposeOverlay()) + } + + this.mapService.pinsMap$ + .pipe(take(1)) + .subscribe(async lm => { + const overlayComp = this.createPinOverlay(pinEvent, lm); + + // when the user wants to dispose a pin overlay (by clicking the X) we + // - make sure the pin is not highlighted anymore + // - remove the pin from the selectedPins list + // - remove lines showing the route to the home hub + overlayComp.afterDispose + .subscribe(pinID => { + this.highlightPin(pinID, false); + + const overlayIdx = this.selectedPins.findIndex(por => por.mapPin.pin.ID === pinEvent.mapPin.pin.ID); + this.selectedPins.splice(overlayIdx, 1) + + this.updatePaths() + this.cdr.markForCheck(); + }) + + // when the user hovers/leaves a pin overlay, we: + // - move the pin-overlay to the top when the user hovers it so stacking order is correct + // - (un)hightlight the pin element on the map + overlayComp.overlayHover + .subscribe(evt => { + this.highlightPin(evt.pinID, evt.type === 'enter') + + // over the overlay component to the top + if (evt.type === 'enter') { + this.selectedPins.forEach(ref => { + if (ref !== overlayComp && ref.overlayRef.hostElement) { + ref.overlayRef.hostElement.style.zIndex = '0'; + } + }) + + overlayComp.overlayRef.hostElement.style.zIndex = ''; + } + }) + + this.selectedPins.push(overlayComp) + + this.updatePaths([]); + this.cdr.markForCheck(); + }) + } + + private updatePaths(additional: Path[] = []) { + const paths = [ + ...(this.selectedPins + .reduce((list, pin) => { + if (pin.routeHome) { + list.push(pin.routeHome) + } + + return [ + ...list, + ...(pin.additionalPaths || []) + ] + }, [] as Path[])), + ...this.liveModePaths, + ...additional + ] + + this.paths = paths.map(p => { + return { + ...p, + attributes: { + class: 'lane', + ...(p.attributes || {}) + } + } + }); + + this.renderPaths(this.paths) + } + + onPinHover(pinEvent: PinEvent | null) { + if (!pinEvent) { + this.updatePaths([]); + this.onCountryHover(null); + + return; + } + + // we also emit a country hover event here to keep the country + // overlay open. + const countryName = this.mapRenderer.countryNames[pinEvent.mapPin.entity.Country] + this.onCountryHover({ + event: pinEvent.event, + countryCode: pinEvent.mapPin.entity.Country, + countryName: countryName!, + }) + + // in developer mode, we show all connected lanes of the hovered pin. + if (this.expertiseService.currentLevel === ExpertiseLevel.Developer) { + this.mapService.pinsMap$ + .pipe(take(1)) + .subscribe(lm => { + const lanes = this.getConnectedLanes(pinEvent?.mapPin, lm) + this.updatePaths(lanes); + this.cdr.markForCheck(); + }) + } + } + + /** + * Marks a process group as selected and either selects one or all exit pins + * of that group. If shiftKey is pressed during click, the ID(s) will be added + * to the list of selected pins instead of replacing it. If shiftKey is pressed + * the process group itself will NOT be displayed as selected. + * + * @private - template only + */ + selectGroup(grp: AppProfile, pin?: MapPin | null, event?: MouseEvent) { + if (!!pin) { + this.selectPins(event, of([pin.pin.ID])) + return; + } + + this.selectPins(event, this.mapService.getExitPinIDsForProfile(grp)) + } + + /** Returns a list of lines that represent the route from pin to home. */ + private getRouteHome(pin: MapPin, lm: Map, includeAllRoutes = false): Path[] { + let pinsToEval: MapPin[] = [pin]; + + // decide whether to draw all connection routes that travel through pin. + if (includeAllRoutes) { + pinsToEval = [ + ...pinsToEval, + ...Array.from(lm.values()) + .filter(p => p.pin.Route?.includes(pin.pin.ID)) + ] + } + + return pinsToEval.map(pin => ({ + id: `route-home-from-${pin.pin.ID}`, + points: (pin.pin.Route || []).map(hop => lm.get(hop)!), + attributes: { + 'in-use': 'true' + } + })); + } + + /** Returns a list of lines the represent all lanes to connected pins of pin */ + private getConnectedLanes(pin: MapPin, lm: Map): Path[] { + let result: Path[] = []; + + // add all lanes for connected hubs + Object.keys(pin.pin.ConnectedTo).forEach(target => { + const p = lm.get(target); + if (!!p) { + result.push({ + id: lineID([pin, p]), + points: [ + pin, + p + ] + }) + } + }); + + return result; + + } + + private async renderPaths(paths: Path[]) { + if (!this.mapRef) { + return; + } + + const ref = this.mapRef! + + const linesGroup: LaneGroup = this.mapRef.select("#line-group")! + + const self = this; + const renderedPaths = linesGroup.selectAll('path') + .data(paths, p => p.id); + + renderedPaths + .enter() + .append('path') + .attr('d', path => { + return self.lineFunc!(path.points) + }) + .attr("stroke-width", d => { + if (d.attributes) { + if (d.attributes['in-use']) { + return 2 / ref.zoomScale + } + } + + return 1 / ref.zoomScale; + }) + .call(sel => { + if (sel.empty()) { + return; + } + const data = sel.datum()?.attributes || {}; + Object.keys(data) + .forEach(key => { + sel.attr(key, data[key]) + }) + }) + .transition("enter-lane") + .duration(d => d.points.length * MapRendererComponent.LineAnimationDuration) + .attrTween('stroke-dasharray', tweenDashEnter) + + renderedPaths.exit() + .interrupt("enter-lane") + .transition("leave-lane") + .duration(200) + .attrTween('stroke-dasharray', tweenDashExit) + .remove(); + } + + private async renderPins(pins: MapPin[]) { + pins = pins.filter(pin => !pin.isOffline || pin.isActive); + + if (!this.mapRef) { + return + } + + const ref = this.mapRef!; + + const countriesWithNodes = new Set(); + + pins.forEach(pin => { + countriesWithNodes.add(pin.entity.Country) + }) + + const pinsGroup = ref.select('#pin-group')! + + const pinElements = pinsGroup + .selectAll('g') + .data(pins, pin => pin.pin.ID) + + const self = this; + + // add new pins + pinElements + .enter() + .append('g') + .append(d => { + const val = MapRendererComponent.MarkerSize / ref.zoomScale; + + if (d.isHome) { + const homeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + homeIcon.setAttribute('r', `${val * 1.25}`) + + return homeIcon; + } + + if (d.pin.VerifiedOwner === 'Safing') { + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') + polygon.setAttribute('points', `0,-${val} -${val},${val} ${val},${val}`) + + return polygon; + } + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + circle.setAttribute('r', `${val}`) + + return circle; + }) + .attr("stroke-width", d => { + if (d.isExit || self.highlightedPins.has(d.pin.ID)) { + return 2 / ref.zoomScale + } + + if (d.isHome) { + return 4.5 / ref.zoomScale + } + + return 1 / ref.zoomScale + }) + .call(selection => { + selection + .style('opacity', 0) + .attr('transform', d => 'scale(0)') + .transition('enter-marker') + /**/.duration(1000) + /**/.attr('transform', d => `scale(1)`) + /**/.style('opacity', 1) + }) + .on('click', function (e: MouseEvent) { + const pin = select(this).datum() as MapPin; + self.onPinClick({ + event: e, + mapPin: pin + }); + }) + .on('mouseenter', function (e: MouseEvent) { + const pin = select(this).datum() as MapPin; + self.onPinHover({ + event: e, + mapPin: pin, + }) + }) + .on('mouseout', function (e: MouseEvent) { + self.onPinHover(null); + }) + + // remove pins from the map that disappeared + pinElements + .exit() + .remove() + + // update all pins to their correct position and update their attributes + pinsGroup.selectAll('g') + .attr('hub-id', d => d.pin.ID) + .attr('is-home', d => d.isHome) + .attr('transform', d => `translate(${ref.projection([d.location.Longitude, d.location.Latitude])})`) + .attr('in-use', d => d.isTransit) + .attr('is-exit', d => d.isExit) + .attr('raise', d => this.highlightedPins.has(d.pin.ID)) + + // update the attributes of the country shapes + ref.worldGroup.selectAll('path') + .attr('has-nodes', d => countriesWithNodes.has(d.properties.iso_a2)) + + // get all in-use pins and raise them to the top + pinsGroup.selectAll('g[in-use=true]') + .raise() + + // finally, re-raise all pins that are highlighted + pinsGroup.selectAll('g[raise=true]') + .raise() + + const activeCountrySet = new Set(); + pins.forEach(pin => { + if (pin.isTransit) { + activeCountrySet.add(pin.pin.ID) + } + }) + + // update the in-use attributes of the country shapes + ref.worldGroup.selectAll('path') + .attr('in-use', d => activeCountrySet.has(d.properties.iso_a2)) + + this.cdr.detectChanges(); + } + + public getPinElem(pinID: string) { + if (!this.mapRef) { + return + } + + return this.mapRef.root + .select("#pin-group") + .select(`g[hub-id=${pinID}]`) + .node() + } + + public clearPinHighlights() { + if (!this.mapRef) { + return + } + + this.mapRef.root + .select('#pin-group') + .select(`g[raise=true]`) + .attr('raise', false) + + this.highlightedPins.clear(); + } + + public highlightPin(pinID: string, highlight: boolean) { + if (highlight) { + this.highlightedPins.add(pinID) + } else { + this.highlightedPins.delete(pinID); + } + + if (!this.mapRef) { + return + } + const pinElemn = this.mapRef!.root + .select("#pin-group") + .select(`g[hub-id=${pinID}]`) + .attr('raise', highlight) + + if (highlight) { + pinElemn + .raise() + } + } +} + +function lineID(l: [MapPin, MapPin]): string { + return [l[0].pin.ID, l[1].pin.ID].sort().join("-") +} + +const tweenDashEnter = function (this: SVGPathElement) { + const len = this.getTotalLength(); + const interpolate = interpolateString(`0, ${len}`, `${len}, ${len}`); + return (t: number) => { + if (t === 1) { + return '0'; + } + return interpolate(t); + } +} + +const tweenDashExit = function (this: SVGPathElement) { + const len = this.getTotalLength(); + const interpolate = interpolateString(`${len}, ${len}`, `0, ${len}`); + return (t: number) => { + if (t === 1) { + return `${len}`; + } + return interpolate(t); + } +} diff --git a/desktop/angular/src/app/pages/spn/spn.module.ts b/desktop/angular/src/app/pages/spn/spn.module.ts new file mode 100644 index 00000000..737ae25f --- /dev/null +++ b/desktop/angular/src/app/pages/spn/spn.module.ts @@ -0,0 +1,69 @@ +import { A11yModule } from '@angular/cdk/a11y'; +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { SfngToggleSwitchModule, SfngTooltipModule, TabModule } from '@safing/ui'; +import { SfngAppIconModule } from 'src/app/shared/app-icon'; +import { CountIndicatorModule } from 'src/app/shared/count-indicator'; +import { CountryFlagModule } from 'src/app/shared/country-flag'; +import { ExpertiseModule } from 'src/app/shared/expertise/expertise.module'; +import { SfngFocusModule } from 'src/app/shared/focus'; +import { SfngMenuModule } from 'src/app/shared/menu'; +import { CommonPipesModule } from 'src/app/shared/pipes'; +import { SpnPageComponent } from './'; +import { CountryDetailsComponent } from './country-details'; +import { CountryOverlayComponent } from './country-overlay'; +import { SpnMapLegendComponent } from './map-legend'; +import { MapRendererComponent } from './map-renderer'; +import { SpnNodeIconComponent } from './node-icon'; +import { PinDetailsComponent } from './pin-details'; +import { SpnPinListComponent } from './pin-list/pin-list'; +import { PinOverlayComponent } from './pin-overlay'; +import { SpnPinRouteComponent } from './pin-route'; +import { SPNFeatureCarouselComponent } from './spn-feature-carousel'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + CountryFlagModule, + SfngTooltipModule, + SfngMenuModule, + SfngFocusModule, + SfngAppIconModule, + SfngToggleSwitchModule, + TabModule, + A11yModule, + ExpertiseModule, + OverlayModule, + CountIndicatorModule, + FontAwesomeModule, + CommonPipesModule, + DragDropModule, + RouterModule, + ], + declarations: [ + MapRendererComponent, + PinOverlayComponent, + CountryOverlayComponent, + CountryDetailsComponent, + SpnNodeIconComponent, + SpnMapLegendComponent, + PinDetailsComponent, + SpnPinRouteComponent, + SPNFeatureCarouselComponent, + SpnPageComponent, + SpnPinListComponent, + ], + exports: [ + SpnPageComponent, + SpnPinRouteComponent, + SpnNodeIconComponent, + MapRendererComponent, + ] +}) +export class SPNModule { } diff --git a/desktop/angular/src/app/pages/spn/utils.ts b/desktop/angular/src/app/pages/spn/utils.ts new file mode 100644 index 00000000..eaeefe49 --- /dev/null +++ b/desktop/angular/src/app/pages/spn/utils.ts @@ -0,0 +1,4 @@ +import { OverlayRef } from '@angular/cdk/overlay'; +import { InjectionToken } from '@angular/core'; + +export const OVERLAY_REF = new InjectionToken('OVERLAY_REF'); diff --git a/desktop/angular/src/app/pages/support/form/index.ts b/desktop/angular/src/app/pages/support/form/index.ts new file mode 100644 index 00000000..b28d7e24 --- /dev/null +++ b/desktop/angular/src/app/pages/support/form/index.ts @@ -0,0 +1 @@ +export * from './support-form'; diff --git a/desktop/angular/src/app/pages/support/form/support-form.html b/desktop/angular/src/app/pages/support/form/support-form.html new file mode 100644 index 00000000..10685d99 --- /dev/null +++ b/desktop/angular/src/app/pages/support/form/support-form.html @@ -0,0 +1,107 @@ +
+ + +
+ +
+
+
+

{{ page?.title }}

+
+ +

+ {{ page?.prologue || page?.shortHelp }} +

+ +
+

{{ page?.repoHelp }}

+ +
+ +

Title

+
+ + Copy +
+ +
+

{{section.title}}

+
+ + Copy +
+ +
+ + +
+

Included Debug Info

+
+ +

+ The following debug information will be sent together with your report. Please check it and remove potentially sensitive + information. The debug information sent with your reports will be saved on Safing's self-hosted pastebin server + and is viewable via its created url. The data is automatically destroyed after one month. +

+
+
+ Portmaster Version: {{version}} + built on {{buildDate}} +
+ Copy +
+ +
+ +
+ + +
+
+ +
+
+

+ Related Issues + +

+
+

+ Public issues related to your title: +

+ +

+ No related issues were found. +

+ +
    +
  • + {{ issue.title }} + {{ issue.closed ? 'closed' : 'opened'}} in {{ repos[issue.repository] || issue.repository + }} by {{ issue.user }} + {{ + issue.createdAt | timeAgo + }} + +
  • +
+
+
diff --git a/desktop/angular/src/app/pages/support/form/support-form.scss b/desktop/angular/src/app/pages/support/form/support-form.scss new file mode 100644 index 00000000..7555aa08 --- /dev/null +++ b/desktop/angular/src/app/pages/support/form/support-form.scss @@ -0,0 +1,253 @@ +:host { + width: 100%; + display: flex; + flex-grow: 1; + flex-direction: column; + height: 100%; +} + +.scroll-container { + overflow: auto; + margin-right: 1rem; + display: flex; + flex-direction: row; + justify-content: center; + flex-grow: 1; + + @apply p-8; + + h3 { + opacity: .9; + font-size: 0.95rem; + } +} + +.form-wrapper { + flex-grow: 2; + + @media (min-width: 1250px) { + max-width: 800px; + } + +} + +.issue-list { + width: 400px; + + margin-left: 2rem; + + &, + ul { + overflow-y: hidden; + } + + .issue { + @apply px-4; + @apply pr-8; + @apply py-4; + @apply rounded; + @apply bg-cards-secondary; + + span { + word-break: keep-all; + } + + display : flex; + flex-direction: column; + position : relative; + cursor : pointer; + + &:not(:last-child) { + margin-bottom: 0.5rem; + } + + .meta { + @apply text-tertiary; + @apply font-normal; + opacity: .7; + font-size: 95%; + } + + &:hover { + @apply bg-cards-tertiary; + } + + fa-icon { + position: absolute; + right: calc((2rem - 12px) / 2); + top: calc(50% - 8px); // actually the half height is 6px but that looks off for the icon we're using + opacity: .3; + } + } +} + +p.prologue { + @apply mb-8; +} + +.page-title { + margin-top: 20px; + margin-bottom: 40px; + position: relative; + border-bottom: 1px solid rgba(255, 255, 255, .2); + + h1 { + position: absolute; + top: -1rem; + background-color: var(--background); + @apply pr-8; + } +} + +.repo-list { + @apply mb-8; + +} + +button { + @apply p-2; + @apply bg-buttons-dark; + @apply border; + @apply border-buttons-dark; + opacity: .4; + + &:not(:last-child) { + @apply mr-1; + } + + &:hover { + @apply bg-buttons-light; + @apply border-buttons-light; + } + + &.selected { + @apply bg-buttons-dark; + @apply border-buttons-light; + opacity: 1; + } +} + +.actions { + @apply mt-8; + @apply pb-16; + + button { + opacity: 1; + @apply bg-transparent; + + &.primary { + @apply bg-buttons-dark; + opacity: 1; + } + + &:hover { + @apply bg-buttons-light; + } + } + +} + +.debug-header { + height: 32px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + position: relative; + @apply bg-cards-primary; + @apply rounded-t; + top: 2px; +} + +textarea { + @apply px-4; + @apply py-2; + min-height: 40px; +} + +textarea, +input[type="text"].title { + @apply font-medium; + @apply border; + @apply border-cards-secondary; + @apply bg-cards-secondary; + padding-right: 4.5rem; // copy button width + + &:hover, + &:active, + &:focus { + @apply border-cards-primary; + } +} + +input[type="text"].title { + padding-left: 1rem; +} + +section { + @apply py-8; + + &:not(:first-of-type) { + @apply pt-0; + } +} + +.input-wrapper { + position: relative; + display: flex; +} + +.copy-button { + user-select: none; + position: absolute; + top: 1px; + right: 0px; + width: 4rem; + height: 31px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: pointer; + @apply bg-buttons-dark; + @apply rounded-sm; + opacity: .5; + + &:hover { + opacity: .9; + } +} + +.section-help { + @apply bg-cards-primary; + @apply border-t; + @apply border-dashed; + @apply border-buttons-light; + @apply p-2; + @apply px-4; + @apply rounded-sm; + color: rgba(255, 255, 255, .6); + font-size: 0.7rem; + position: relative; + width: 100%; + display: flex; + flex-direction: column; +} + +.gh-author { + @apply mt-8; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + + .input-wrapper { + padding-top: 2px; + @apply pr-2; + } +} + +input[type="text"].missing, +textarea.missing { + @apply border-info-red; +} diff --git a/desktop/angular/src/app/pages/support/form/support-form.ts b/desktop/angular/src/app/pages/support/form/support-form.ts new file mode 100644 index 00000000..1b2e8ed1 --- /dev/null +++ b/desktop/angular/src/app/pages/support/form/support-form.ts @@ -0,0 +1,258 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { Component, DestroyRef, OnInit, TrackByFunction, ViewChild, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DebugAPI } from '@safing/portmaster-api'; +import { ConfirmDialogConfig, SfngDialogService } from '@safing/ui'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { debounceTime, mergeMap } from 'rxjs/operators'; +import { SessionDataService, StatusService } from 'src/app/services'; +import { Issue, SupportHubService } from 'src/app/services/supporthub.service'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; +import { fadeInAnimation, fadeInListAnimation, moveInOutAnimation } from 'src/app/shared/animations'; +import { FuzzySearchService } from 'src/app/shared/fuzzySearch'; +import { SupportPage, supportTypes } from '../pages'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; +import { SupportProgressDialogComponent, TicketData, TicketInfo } from '../progress-dialog'; + +@Component({ + templateUrl: './support-form.html', + styleUrls: ['./support-form.scss'], + animations: [fadeInAnimation, moveInOutAnimation, fadeInListAnimation] +}) +export class SupportFormComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly search$ = new BehaviorSubject(''); + private readonly integration = inject(INTEGRATION_SERVICE); + + page: SupportPage | null = null; + + debugData: string = ''; + title: string = ''; + form: { [key: string]: string } = {} + selectedRepo: string = ''; + haveGhAccount = false; + version: string = ''; + buildDate: string = ''; + titleMissing = false; + + relatedIssues: Issue[] = []; + allIssues: Issue[] = []; + repos: { [repo: string]: string } = {}; + + @ViewChild(CdkScrollable) + scrollContainer: CdkScrollable | null = null; + + trackIssue: TrackByFunction = (_: number, issue: Issue) => issue.url; + + constructor( + private route: ActivatedRoute, + private router: Router, + private uai: ActionIndicatorService, + private debugapi: DebugAPI, + private statusService: StatusService, + private dialog: SfngDialogService, + private supporthub: SupportHubService, + private searchService: FuzzySearchService, + private sessionService: SessionDataService, + ) { } + + ngOnInit() { + this.supporthub.loadIssues().subscribe(issues => { + issues = issues.reverse(); + this.allIssues = issues; + this.relatedIssues = issues; + }) + + this.search$.pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(200), + ) + .subscribe((text) => { + this.relatedIssues = this.searchService.searchList(this.allIssues, text, { + disableHighlight: true, + shouldSort: true, + isCaseSensitive: false, + minMatchCharLength: 4, + keys: [ + 'title', + 'body', + ], + }).map(res => res.item) + }) + + this.statusService.getVersions() + .subscribe(status => { + this.version = status.Core.Version; + this.buildDate = status.Core.BuildDate; + }) + + this.route.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(params => { + const id = params.get("id") + for (let pIdx = 0; pIdx < supportTypes.length; pIdx++) { + const pageSection = supportTypes[pIdx]; + const page = pageSection.choices.find(choice => choice.type !== 'link' && choice.id === id); + if (!!page) { + this.page = page as SupportPage; + break; + } + } + + if (!this.page) { + this.router.navigate(['..']); + return; + } + this.title = ''; + this.form = {}; + this.selectedRepo = 'portmaster'; + this.debugData = ''; + this.repos = {}; + this.page.sections.forEach(section => this.form[section.title] = ''); + this.page.repositories?.forEach(repo => this.repos[repo.repo] = repo.name) + + // try to restore from session service + this.sessionService.restore(this.page.id, this); + + if (this.page.includeDebugData) { + this.debugapi.getCoreDebugInfo('github') + .subscribe({ + next: data => this.debugData = data, + error: err => this.uai.error('Failed to get Debug Data', this.uai.getErrorMessgae(err)) + }) + } + }) + } + + onModelChange() { + if (!this.page) { + return; + } + this.sessionService.save(this.page.id, this, ['title', 'form', 'selectedRepo', 'haveGhAccount']); + } + + selectRepo(repo: string) { + this.selectedRepo = repo; + this.onModelChange(); + } + + searchIssues(text: string) { + this.onModelChange(); + this.search$.next(text); + } + + copyToClipboard(what: string) { + this.integration.writeToClipboard(what) + .then(() => this.uai.success("Copied to Clipboard")) + .catch(() => this.uai.error('Failed to Copy to Clipboard')); + } + + validate(): boolean { + this.titleMissing = this.title === ''; + const valid = !this.titleMissing; + if (!valid) { + this.scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }) + } + return valid; + } + + createIssue(type: 'github' | 'private', genUrl?: boolean, email?: string) { + const ticketData: TicketData = { + repo: this.selectedRepo || '', + title: this.title, + debugInfo: this.debugData, + sections: this.page?.sections.map(section => ({ + title: section.title, + body: this.form[section.title], + })) || [], + } + + let issue: TicketInfo; + + switch (type) { + case 'github': + issue = { + type: 'github', + generateUrl: genUrl || false, + preset: this.page!.ghIssuePreset || '', + ...ticketData + }; + + break; + + case 'private': + issue = { + type: 'private', + email: email, + ...ticketData + } + + break; + } + + SupportProgressDialogComponent.open(this.dialog, issue) + .subscribe(() => { + this.sessionService.delete(this.page?.id || ''); + }); + } + + createOnGithub(genUrl?: boolean) { + if (!this.validate()) { + return; + } + + if (genUrl === undefined && this.haveGhAccount) { + genUrl = true; + } + + if (genUrl === undefined) { + this.dialog.confirm({ + canCancel: true, + caption: 'Caution', + header: 'Create Issue on GitHub', + message: 'You can easily create the issue with your own GitHub account. Or create the GitHub issue privately, but then we will have no way to communicate with you for further information.', + buttons: [ + { id: 'createWithout', text: 'Create Without Account', class: 'outline' }, + { id: 'openGithub', text: 'Use My Account' }, + ] + }) + .onAction('openGithub', () => { + this.createIssue('github', true) + }) + .onAction('createWithout', () => { + this.createIssue('github', false) + }) + return; + } + } + + openIssue(issue: Issue) { + this.integration.openExternal(issue.url); + } + + createPrivateTicket() { + if (!this.validate()) { + return; + } + + const opts: ConfirmDialogConfig = { + caption: 'Info', + canCancel: true, + header: 'How should we stay in touch?', + message: 'Please enter your email address so we can write back and forth until the issue is concluded.', + inputModel: '', + inputPlaceholder: 'Optional Email', + inputType: 'text', + buttons: [ + { id: '', class: 'outline', text: 'Cancel' }, + { id: 'create', text: 'Create Ticket' }, + ], + } + this.dialog.confirm(opts) + .onAction('create', () => { + this.createIssue('private', undefined, opts.inputModel); + }); + } + +} diff --git a/desktop/angular/src/app/pages/support/index.ts b/desktop/angular/src/app/pages/support/index.ts new file mode 100644 index 00000000..5f9360ad --- /dev/null +++ b/desktop/angular/src/app/pages/support/index.ts @@ -0,0 +1 @@ +export * from './support'; diff --git a/desktop/angular/src/app/pages/support/pages.ts b/desktop/angular/src/app/pages/support/pages.ts new file mode 100644 index 00000000..af0f650c --- /dev/null +++ b/desktop/angular/src/app/pages/support/pages.ts @@ -0,0 +1,165 @@ +export interface PageSections { + title?: string; + choices: SupportType[]; + style?: 'small'; +} + +export interface QuestionSection { + title: string; + help?: string; +} + +export interface SupportPage { + type?: undefined; + id: string; + title: string; + shortHelp: string; + repoHelp?: string; + prologue?: string; + epilogue?: string; + sections: QuestionSection[]; + privateTicket?: boolean; + ghIssuePreset?: string; + includeDebugData?: boolean; + repositories?: { repo: string, name: string }[]; +} + +export interface ExternalLink { + type: 'link', + url: string; + title: string; + shortHelp: string; +} + +export type SupportType = SupportPage | ExternalLink; + +export const supportTypes: PageSections[] = [ + { + title: "Resources", + choices: [ + { + type: 'link', + title: '📘 Portmaster Wiki & FAQ', + url: 'https://wiki.safing.io/?source=Portmaster', + shortHelp: 'Search the Portmaster knowledge base and FAQ.', + }, + { + type: 'link', + title: '🔖 Settings Handbook', + url: 'https://docs.safing.io/portmaster/settings?source=Portmaster', + shortHelp: 'A reference document of all Portmaster settings.' + }, + { + type: 'link', + title: '📑 Safing Blog', + url: 'https://safing.io/blog?source=Portmaster', + shortHelp: 'Read our blog posts and announcements.', + } + ] + }, + { + title: "Communities & Support", + style: 'small', + choices: [ + { + type: 'link', + title: 'Join us on Discord', + url: 'https://discord.gg/safing', + shortHelp: 'Get help from the community and our AI bot on Discord.' + }, + { + type: 'link', + title: 'Follow us on Mastodon', + url: 'https://fosstodon.org/@safing', + shortHelp: 'Get updates and privacy jokes on Mastodon.' + }, + { + type: 'link', + title: 'Follow us on Twitter', + url: 'https://twitter.com/SafingIO', + shortHelp: 'Get updates and privacy jokes on Twitter.' + }, + { + type: 'link', + title: 'Safing Support via Email', + url: 'mailto:support@safing.io', + shortHelp: 'As a subscriber, reach out to the Safing team directly.' + } + ] + }, + { + title: "Make a Report", + style: 'small', + choices: [ + { + id: "report-bug", + title: "🐞 Report a Bug", + shortHelp: "Found a bug? Report your discovery and make the Portmaster better for everyone.", + repoHelp: "Where did the bug take place?", + sections: [ + { + title: "What happened?", + help: "Describe what happened in detail" + }, + { + title: "What did you expect to happen?", + help: "Describe what you expected to happen instead" + }, + { + title: "How did you reproduce it?", + help: "Describe how to reproduce the issue" + }, + { + title: "Additional information", + help: "Provide extra details if needed" + }, + ], + includeDebugData: true, + privateTicket: true, + ghIssuePreset: "report-bug.md", + repositories: [] + }, + { + id: "give-feedback", + title: "💡 Suggest an Improvement", + shortHelp: "Suggest an enhancement or a new feature for Portmaster.", + repoHelp: "What would you would like to improve?", + sections: [ + { + title: "What would you like to add or change?", + }, + { + title: "Why do you and others need this?" + } + ], + includeDebugData: false, + privateTicket: true, + ghIssuePreset: "suggest-feature.md", + repositories: [] + }, + { + id: "compatibility-report", + title: "📝 Make a Compatibility Report", + shortHelp: "Report Portmaster in/compatibility with Linux Distros, VPN Clients or general Software.", + sections: [ + { + title: "What worked?", + help: "Describe what worked" + }, + { + title: "What did not work?", + help: "Describe what did not work in detail" + }, + { + title: "Additional information", + help: "Provide extra details if needed" + }, + ], + includeDebugData: true, + privateTicket: true, + ghIssuePreset: "report-compatibility.md", + repositories: [] // not needed with the default being "portmaster" + }, + ], + } +] diff --git a/desktop/angular/src/app/pages/support/progress-dialog/index.ts b/desktop/angular/src/app/pages/support/progress-dialog/index.ts new file mode 100644 index 00000000..0dfbf366 --- /dev/null +++ b/desktop/angular/src/app/pages/support/progress-dialog/index.ts @@ -0,0 +1 @@ +export * from './progress-dialog'; diff --git a/desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.html b/desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.html new file mode 100644 index 00000000..2504a56e --- /dev/null +++ b/desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.html @@ -0,0 +1,114 @@ + +
+ + Status + +
+ + + + +
+ + + Uploading debug data .... + + + + + Creating GitHub issue ... + + + + + Creating private support ticket ... + +
+
+
+ + + + + + + + + + + + Ticket prepared successfully + + + + Ticket created successfully! + + + + +
+ Use the following button to open the pre-filled GitHub issue form: + +
+ +
+ +
+
+ +
+ + We successfully create the issue on GitHub for you. +
+ Use the following link to check for updates: +
+ + {{ url }} +
+ + + We will contact you as soon as possbile. + +
+ + +
+ + + + + + + + Failed to create Support Ticket + + + + + + An error occured while creating your support ticket: + + + + {{ error || 'Unknown Error' }} + +
+ + +
+ + + + + + +
diff --git a/desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.ts b/desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.ts new file mode 100644 index 00000000..32deb205 --- /dev/null +++ b/desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.ts @@ -0,0 +1,173 @@ +import { ComponentPortal } from "@angular/cdk/portal"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, EventEmitter, OnInit, inject } from "@angular/core"; +import { SFNG_DIALOG_REF, SfngDialogRef, SfngDialogService } from "@safing/ui"; +import { Observable, map, mergeMap, of } from "rxjs"; +import { INTEGRATION_SERVICE } from "src/app/integration"; +import { SupportHubService, SupportSection } from "src/app/services"; +import { ActionIndicatorService } from "src/app/shared/action-indicator"; + +export interface TicketData { + debugInfo: string; + repo: string; + title: string; + sections: SupportSection[]; +} + +export interface GithubIssue extends TicketData { + type: 'github', + generateUrl?: boolean; + preset?: string; +} + +export interface PrivateTicket extends TicketData { + type: 'private', + email?: string, +} + +export type TicketInfo = GithubIssue | PrivateTicket; + + +@Component({ + templateUrl: './progress-dialog.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + @apply block flex flex-col gap-8 relative; + } + `, + ] +}) +export class SupportProgressDialogComponent implements OnInit { + + /** Static method to open the support-progress dialog. */ + static open(dialog: SfngDialogService, data: TicketInfo): Observable { + const ref = dialog.create(SupportProgressDialogComponent, { + data, + dragable: true, + backdrop: false, + autoclose: false, + }); + + return (ref.contentRef() as ComponentRef) + .instance + .done; + } + + + private readonly cdr = inject(ChangeDetectorRef); + private readonly supporthub = inject(SupportHubService); + private readonly uai = inject(ActionIndicatorService); + + readonly integration = inject(INTEGRATION_SERVICE); + + readonly dialogRef: SfngDialogRef = inject(SFNG_DIALOG_REF); + + /** Holds the current state of the issue-creation */ + state: '' | 'debug-info' | 'create-issue' | 'create-ticket' | 'done' | 'error' = ''; + + /** The URL to the github issue once it was created. */ + url: string = ''; + + /** The error message if one occured */ + error: string = ''; + + /** Emits once the issue has been created successfully */ + done = new EventEmitter; + + ngOnInit(): void { + this.createSupportRequest(); + } + + setState(state: typeof this['state']) { + this.state = state; + this.cdr.detectChanges(); + } + + createSupportRequest(): void { + const data = this.dialogRef.data; + let stream = of('') + + // Upload debug info + if (data.debugInfo) { + stream = new Observable((observer) => { + this.state = 'debug-info'; + this.cdr.detectChanges(); + + this.supporthub.uploadText('debug-info', data.debugInfo) + .subscribe(observer); + }) + } + + // either create on github or create a private ticket through support-hub + if (data.type === 'github') { + stream = stream.pipe( + mergeMap((url) => { + this.state = 'create-issue'; + this.cdr.detectChanges(); + + return this.supporthub.createIssue( + data.repo, + data.preset || '', + data.title, + data.sections, + url, + { + generateUrl: data.generateUrl || false + }, + ); + }) + ) + } else { + stream = stream.pipe( + mergeMap((url) => { + this.state = 'create-ticket'; + this.cdr.markForCheck(); + + return this.supporthub.createTicket( + data.repo, + data.title, + data.email || '', + data.sections, + url + ) + }), + map(() => '') + ) + } + + stream.subscribe({ + next: (url) => { + this.state = 'done'; + this.url = url; + this.cdr.markForCheck(); + + this.done.next(); + }, + + error: (err) => { + console.error("error", err); + + this.state = 'error'; + if (err instanceof HttpErrorResponse && err.error instanceof ProgressEvent) { + this.error = err.statusText; + } else { + this.error = this.uai.getErrorMessage(err); + } + + this.cdr.markForCheck(); + } + }); + } + + copyUrl() { + if (!this.url) { + return + } + + this.integration.writeToClipboard(this.url) + .then(() => this.uai.success('URL Copied To Clipboard')) + .catch(err => this.uai.error('Failed to Copy To Clipboard', this.uai.getErrorMessage(err))) + } +} diff --git a/desktop/angular/src/app/pages/support/support.html b/desktop/angular/src/app/pages/support/support.html new file mode 100644 index 00000000..2ad9eca2 --- /dev/null +++ b/desktop/angular/src/app/pages/support/support.html @@ -0,0 +1,50 @@ +
+ + +
+ +
+
+
+

{{section.title}}

+
+ +
+
+ + +

{{item.title}}

+ + +
+
+
+
+ + + +
diff --git a/desktop/angular/src/app/pages/support/support.scss b/desktop/angular/src/app/pages/support/support.scss new file mode 100644 index 00000000..fd3ddd50 --- /dev/null +++ b/desktop/angular/src/app/pages/support/support.scss @@ -0,0 +1,77 @@ +:host { + width: 100%; + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; +} + +.list-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + + .section-title { + margin-top: 20px; + margin-bottom: 40px; + position: relative; + border-bottom: 1px solid rgba(255, 255, 255, .2); + + h4 { + position: absolute; + top: -0.5rem; + background-color: var(--background); + @apply pr-8; + } + } + + .page-section { + width: 100%; + display: flex; + justify-content: stretch; + align-items: stretch; + flex-direction: column; + @apply px-4; + + @media (min-width: 1250px) { + max-width: 800px; + } + } + + .option-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-gap: 20px; + grid-auto-rows: 1fr; + + width: 100%; + margin-bottom: 20px; + + section { + @apply bg-cards-secondary; + @apply p-8; + @apply rounded; + transition: all 250ms ease-in-out; + position: relative; + cursor: pointer; + + &:hover { + @apply bg-cards-tertiary; + } + + fa-icon { + position: absolute; + top: 1rem; + right: 1rem; + opacity: .4; + } + } + + + } + + .small .option-list section { + @apply p-4; + } +} diff --git a/desktop/angular/src/app/pages/support/support.ts b/desktop/angular/src/app/pages/support/support.ts new file mode 100644 index 00000000..4bd89940 --- /dev/null +++ b/desktop/angular/src/app/pages/support/support.ts @@ -0,0 +1,97 @@ +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import { BehaviorSubject, combineLatest, debounceTime } from 'rxjs'; +import { Issue, SupportHubService } from 'src/app/services'; +import { fadeInAnimation, fadeInListAnimation } from 'src/app/shared/animations'; +import { FuzzySearchService } from 'src/app/shared/fuzzySearch'; +import { SupportType, supportTypes } from './pages'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; + +@Component({ + templateUrl: './support.html', + styleUrls: ['./support.scss'], + animations: [ + fadeInListAnimation, + fadeInAnimation, + ] +}) +export class SupportPageComponent implements OnInit { + // make supportTypes available in the page template. + readonly supportTypes = supportTypes; + + private readonly destroyRef = inject(DestroyRef); + private readonly integration = inject(INTEGRATION_SERVICE); + + /** @private The current search term for the FAQ entries. */ + searchFaqs = new BehaviorSubject(''); + + searchTerm: string = ''; + + /** A list of all faq entries loaded from the Support Hub */ + allFaqEntries: Issue[] = []; + + /** A list of faq entries to show */ + faqEntries: Issue[] = []; + + constructor( + private router: Router, + private searchService: FuzzySearchService, + private supportHub: SupportHubService, + ) { } + + ngOnInit(): void { + combineLatest([ + this.searchFaqs, + this.supportHub.loadIssues() + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(200), + ) + .subscribe(([searchTerm, allFaqEntries]) => { + this.allFaqEntries = allFaqEntries + .filter(issue => issue.labels?.includes("faq")) + .map(issue => { + return { + ...issue, + + title: issue.title.replace("FAQ: ", "") + } + }) + + if (searchTerm === '') { + this.faqEntries = [ + ...this.allFaqEntries + ] + + return; + } + + this.faqEntries = this.searchService.searchList(this.allFaqEntries, searchTerm, { + disableHighlight: true, + shouldSort: true, + isCaseSensitive: false, + minMatchCharLength: 3, + keys: [ + 'title', + 'body', + ], + }).map(res => res.item) + }) + } + + openIssue(issue: Issue) { + this.integration.openExternal(issue.url); + } + + openPage(item: SupportType) { + if (item.type === 'link') { + this.integration.openExternal(item.url); + return; + } + + this.router.navigate(['/support', item.id]); + } +} + diff --git a/desktop/angular/src/app/prompt-entrypoint/prompt-entrypoint.ts b/desktop/angular/src/app/prompt-entrypoint/prompt-entrypoint.ts new file mode 100644 index 00000000..23bf9f01 --- /dev/null +++ b/desktop/angular/src/app/prompt-entrypoint/prompt-entrypoint.ts @@ -0,0 +1,78 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, TrackByFunction, inject } from "@angular/core"; +import { AppProfile, AppProfileService, PortapiService } from "@safing/portmaster-api"; +import { combineLatest, combineLatestAll, forkJoin, map, merge, mergeAll, of, switchMap } from "rxjs"; +import { ConnectionPrompt, NotificationType, NotificationsService } from "../services"; +import { SfngAppIconModule } from "../shared/app-icon"; +import { getCurrent } from '@tauri-apps/api/window'; +import { CountryFlagModule } from "../shared/country-flag"; + +interface Prompt { + prompts: ConnectionPrompt[]; + profile: AppProfile; +} + +@Component({ + standalone: true, + selector: 'app-root', + templateUrl: './prompt.html', + imports: [ + CommonModule, + SfngAppIconModule, + CountryFlagModule + ] +}) +export class PromptEntryPointComponent implements OnInit { + private readonly notificationService = inject(NotificationsService); + private readonly portapi = inject(PortapiService); + private readonly profileService = inject(AppProfileService); + + prompts: Prompt[] = []; + + trackPrompt: TrackByFunction = (_, p) => p.EventID; + trackProfile: TrackByFunction = (_, p) => p.profile._meta!.Key; + + ngOnInit(): void { + + this.notificationService + .new$ + .pipe( + map(notifs => { + return notifs.filter(n => n.Type === NotificationType.Prompt && n.EventID.startsWith("filter:prompt")) + }), + switchMap(notifications => { + const distictProfiles = new Map(); + notifications.forEach(n => { + const key = `${n.EventData!.Profile.Source}/${n.EventData!.Profile.ID}` + const arr = distictProfiles.get(key) || []; + arr.push(n); + distictProfiles.set(key, arr); + }); + + if (distictProfiles.size === 0) { + return of([]); + } + + return combineLatest(Array.from(distictProfiles.entries()).map(([key, prompts]) => forkJoin({ + profile: this.profileService.getAppProfile(key), + prompts: of(Array.from(prompts)) + }))); + }) + ) + .subscribe(result => { + this.prompts = result; + + // show the prompt now since we're ready + if (this.prompts.length) { + getCurrent()!.show(); + } + }) + } + + selectAction(prompt: ConnectionPrompt, action: string) { + prompt.SelectedActionID = action; + + this.portapi.update(prompt._meta!.Key, prompt) + .subscribe(); + } +} diff --git a/desktop/angular/src/app/prompt-entrypoint/prompt.html b/desktop/angular/src/app/prompt-entrypoint/prompt.html new file mode 100644 index 00000000..8667398e --- /dev/null +++ b/desktop/angular/src/app/prompt-entrypoint/prompt.html @@ -0,0 +1,65 @@ +
+ +
+ +

Portmaster

+
+ +
+ + + + +
+
+ + + {{ prompt.profile.Name }} + {{ prompt.profile.LinkedPath }} + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Domain: + + + {{ prompt.EventData?.Entity?.Domain || 'N/A' }} + + +
+ +
+
IP:{{ prompt.EventData?.Entity?.IP || 'N/A' }}
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/desktop/angular/src/app/services/index.ts b/desktop/angular/src/app/services/index.ts new file mode 100644 index 00000000..d4b95f1d --- /dev/null +++ b/desktop/angular/src/app/services/index.ts @@ -0,0 +1,8 @@ +export { NotificationsService } from './notifications.service'; +export * from './notifications.types'; +export * from './session-data.service'; +export { StatusService } from './status.service'; +export * from './status.types'; +export * from './supporthub.service'; +export * from './ui-state.service'; + diff --git a/desktop/angular/src/app/services/notifications.service.spec.ts b/desktop/angular/src/app/services/notifications.service.spec.ts new file mode 100644 index 00000000..8789bf32 --- /dev/null +++ b/desktop/angular/src/app/services/notifications.service.spec.ts @@ -0,0 +1,354 @@ +import { TestBed } from '@angular/core/testing'; +import { WebsocketService } from '@safing/portmaster-api'; +import { MockWebSocketSubject } from '@safing/portmaster-api/testing'; +import { PartialObserver } from 'rxjs'; +import { NotificationsService } from './notifications.service'; +import { Notification, NotificationType } from './notifications.types'; + +describe('NotificationsService', () => { + let service: NotificationsService; + let mock: MockWebSocketSubject; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: WebsocketService, + useValue: MockWebSocketSubject, + } + ] + }); + service = TestBed.inject(NotificationsService); + mock = MockWebSocketSubject.lastMock!; + }); + + afterEach(() => { + mock.close(); + }) + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should allow to query for notifications', () => { + const observer = createSpyObserver(); + service.query("updates:").subscribe(observer); + + mock.expectLastMessage() + mock.expectLastMessage('type').toBe('query') + mock.expectLastMessage('query').toBe('notifications:all/updates:') + + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'ok', + data: { + ID: 'updates:core-update-available', + Message: 'Update available', + }, + key: 'notifications:all/updates:core-update-available' + }) + + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'ok', + data: { + ID: 'updates:ui-reload-required', + Message: 'UI reload required', + }, + key: 'notifications:all/updates:ui-reload-required' + }) + + // query collects all notifications using toArray + // so nothing should be nexted yet. + expect(observer.next).not.toHaveBeenCalled() + expect(observer.error).not.toHaveBeenCalled() + expect(observer.complete).not.toHaveBeenCalled() + + // finish the strea + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'done' + }) + + expect(observer.next).toHaveBeenCalledWith([ + { + ID: 'updates:core-update-available', + Message: 'Update available', + }, + { + ID: 'updates:ui-reload-required', + Message: 'UI reload required', + } + ]) + expect(observer.error).not.toHaveBeenCalled() + expect(observer.complete).toHaveBeenCalled() + }); + + describe('execute notification actions', () => { + it('should work using a notif object', () => { + let observer = createSpyObserver(); + let notif: any = { + ID: 'updates:core-update-available', + Message: 'An update is available', + Type: NotificationType.Info, + AvailableActions: [{ ID: "restart", Text: "Restart" }], + } + + service.execute(notif, "restart").subscribe(observer); + + expect(observer.error).not.toHaveBeenCalled() + + mock.expectLastMessage('type').toBe('update'); + mock.expectLastMessage('key').toBe('notifications:all/updates:core-update-available'); + mock.expectLastMessage('data').toEqual({ + ID: 'updates:core-update-available', + SelectedActionID: 'restart', + }); + + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'success' + }) + + expect(observer.next).toHaveBeenCalledWith(undefined); + expect(observer.error).not.toHaveBeenCalled(); + expect(observer.complete).toHaveBeenCalled(); + }); + + it('should throw when executing an unknown action using a notif object', () => { + let observer = createSpyObserver(); + let notif: any = { + ID: 'updates:core-update-available', + Message: 'An update is available', + Type: NotificationType.Info, + AvailableActions: [{ ID: "restart", Text: "Restart" }], + } + + service.execute(notif, "restart-with-typo").subscribe(observer); + + expect(observer.error).toHaveBeenCalled() + expect(mock.lastMessageSent).toBeUndefined(); + }); + + it('should work using a key', () => { + let observer = createSpyObserver(); + service.execute("updates:core-update-available", "restart").subscribe(observer); + + expect(observer.error).not.toHaveBeenCalled() + + mock.expectLastMessage('type').toBe('update'); + mock.expectLastMessage('key').toBe('notifications:all/updates:core-update-available'); + mock.expectLastMessage('data').toEqual({ + ID: 'updates:core-update-available', + SelectedActionID: 'restart', + }); + + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'success' + }) + + expect(observer.next).toHaveBeenCalledWith(undefined); + expect(observer.error).not.toHaveBeenCalled(); + expect(observer.complete).toHaveBeenCalled(); + }); + }) + + describe('resolving pending actions', () => { + it('should work using a notif object', () => { + let observer = createSpyObserver(); + let notif: any = { + ID: 'updates:core-update-available', + Message: 'An update is available', + Type: NotificationType.Info, + Responded: Math.round(Date.now() / 1000), + SelectedActionID: "restart", + } + + service.resolvePending(notif, 100).subscribe(observer) + + expect(observer.error).not.toHaveBeenCalled() + + mock.expectLastMessage('type').toBe('update'); + mock.expectLastMessage('key').toBe('notifications:all/updates:core-update-available'); + mock.expectLastMessage('data').toEqual({ + ID: 'updates:core-update-available', + Executed: 100, + }); + + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'success' + }) + + expect(observer.next).toHaveBeenCalledWith(undefined); + expect(observer.error).not.toHaveBeenCalled(); + expect(observer.complete).toHaveBeenCalled(); + }); + + it('should throw on an executed notification using a notif object', () => { + let observer = createSpyObserver(); + let notif: any = { + ID: 'updates:core-update-available', + Message: 'An update is available', + Type: NotificationType.Info, + SelectedActionID: 'restart', + Responded: Math.round(Date.now() / 1000), + Executed: Math.round(Date.now() / 1000), + } + + service.resolvePending(notif).subscribe(observer); + + expect(observer.error).toHaveBeenCalled() + expect(mock.lastMessageSent).toBeUndefined(); + }); + + it('should work using a key', () => { + let observer = createSpyObserver(); + service.resolvePending("updates:core-update-available", 100).subscribe(observer); + + expect(observer.error).not.toHaveBeenCalled() + + mock.expectLastMessage('type').toBe('update'); + mock.expectLastMessage('key').toBe('notifications:all/updates:core-update-available'); + mock.expectLastMessage('data').toEqual({ + ID: 'updates:core-update-available', + Executed: 100, + }); + + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + type: 'success' + }) + + expect(observer.next).toHaveBeenCalledWith(undefined); + expect(observer.error).not.toHaveBeenCalled(); + expect(observer.complete).toHaveBeenCalled(); + }); + }); + + describe('watching notifications', () => { + it('should be possible to watch for new and action-required notifs only', () => { + const observer = createSpyObserver(); + service.new$.subscribe(observer); + + let send = (msg: any) => { + mock.lastMultiplex!.next({ + id: mock.lastRequestId!, + data: msg, + type: 'ok', + key: "notifications:all/" + msg.ID, + }) + } + + let n1 = { + ID: "new-notif-1", + Message: "a new notification", + Responded: 0, + Executed: 0, + Expires: Math.round(Date.now() / 1000) + 60 * 60, + } + let n2 = { + ID: "new-notif-2", + Message: "a new notification", + Responded: 0, + Executed: 0, + Expires: 0, + AvailableActions: [{ ID: "action-id", Text: "some action" }], + } + let expired = { + ID: "new-notif-3", + Message: "a new notification", + Responded: 0, + Executed: 0, + Expires: 100, + } + let pending = { + ID: "new-notif-4", + Message: "a new notification", + Responded: Math.round(Date.now() / 1000), + Executed: 0, + SelectedActionID: "test", + } + + send(n1) + send(expired) + send(n2) + send(pending) + + expect(observer.complete).not.toHaveBeenCalled() + expect(observer.error).not.toHaveBeenCalled() + expect(observer.next).toHaveBeenCalledTimes(2) + expect(observer.next).toHaveBeenCalledWith(n1) + expect(observer.next).toHaveBeenCalledWith(n2) + }) + }) + + describe('creating notifications', () => { + it('should be possible using an object', () => { + let notification: Partial> = { + ID: 'my-awesome-notification', + AvailableActions: [ + { ID: 'action-no', Text: 'No' }, + { ID: 'force-no', Text: 'Hell No' } + ], + Message: 'Update complete, do you want to reboot?', + Persistent: true, + Type: NotificationType.Warning, + } + + let observer = createSpyObserver(); + service.create(notification).subscribe(observer); + + expect(observer.error).not.toHaveBeenCalled(); + + mock.expectLastMessage('type').toBe('create') + mock.expectLastMessage('key').toBe('notifications:all/my-awesome-notification') + mock.expectLastMessage('data').toEqual(notification); + expect(notification.Created).toBeTruthy(); + + mock.lastMultiplex!.next({ + type: 'success', + id: mock.lastRequestId!, + }) + + expect(observer.complete).toHaveBeenCalled() + expect(observer.error).not.toHaveBeenCalled() + expect(observer.next).toHaveBeenCalledWith(undefined) + }) + + it('should be possible using parameters', () => { + let observer = createSpyObserver(); + service.create('my-param-notification', 'message', NotificationType.Prompt, { + Persistent: true, + Created: 100, + }).subscribe(observer); + + expect(observer.error).not.toHaveBeenCalled(); + + mock.expectLastMessage('type').toBe('create') + mock.expectLastMessage('key').toBe('notifications:all/my-param-notification') + mock.expectLastMessage('data').toEqual({ + Type: NotificationType.Prompt, + ID: 'my-param-notification', + Message: 'message', + Created: 100, + Persistent: true, + }); + + mock.lastMultiplex!.next({ + type: 'success', + id: mock.lastRequestId!, + }) + + expect(observer.complete).toHaveBeenCalled() + expect(observer.error).not.toHaveBeenCalled() + expect(observer.next).toHaveBeenCalledWith(undefined) + + }) + }) +}); + +function createSpyObserver(): PartialObserver { + return jasmine.createSpyObj("observer", ["next", "error", "complete"]) +} diff --git a/desktop/angular/src/app/services/notifications.service.ts b/desktop/angular/src/app/services/notifications.service.ts new file mode 100644 index 00000000..b15949f2 --- /dev/null +++ b/desktop/angular/src/app/services/notifications.service.ts @@ -0,0 +1,395 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, TrackByFunction, inject } from '@angular/core'; +import { Params, Router } from '@angular/router'; +import { PortapiService, RetryableOpts } from '@safing/portmaster-api'; +import { BehaviorSubject, Observable, combineLatest, defer, throwError } from 'rxjs'; +import { map, share, toArray } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; +import { ActionIndicatorService } from '../shared/action-indicator'; +import { Action, ActionHandler, NetqueryAction, Notification, NotificationState, NotificationType, OpenPageAction, OpenProfileAction, OpenSettingAction, OpenURLAction, PageIDs, WebhookAction } from './notifications.types'; +import { VirtualNotification } from './virtual-notification'; +import { INTEGRATION_SERVICE } from '../integration'; + +@Injectable({ + providedIn: 'root' +}) +export class NotificationsService { + private readonly integration = inject(INTEGRATION_SERVICE); + + /** + * A {@link TrackByFunction} from tracking notifications. + */ + static trackBy: TrackByFunction> = function (_: number, n: Notification) { + return n.EventID; + }; + + /** + * This object contains handler methods for all + * notification action types we currently support. + */ + private actionHandler: { + [key in Action['Type']]: (a: any) => Promise; + } = { + '': async () => { }, + 'open-url': async (a: OpenURLAction) => { + await this.integration.openExternal(a.Payload); + }, + 'open-profile': (a: OpenProfileAction) => this.router.navigate([ + '/app', ...a.Payload.split('/') + ]), + 'open-setting': (a: OpenSettingAction) => { + if (a.Payload.Profile) { + return this.router.navigate(['/app', ...a.Payload.Profile.split('/')], { + queryParams: { + setting: a.Payload.Key, + tab: 'settings' + } + }) + } + return this.router.navigate(['/settings'], { + queryParams: { + setting: a.Payload.Key + } + }) + }, + "open-page": (a: OpenPageAction) => { + let pageID: keyof typeof PageIDs | null = null; + let queryParams: Params | null = null; + + if (typeof a.Payload === 'string') { + pageID = a.Payload; + queryParams = {}; + } else { + pageID = a.Payload.id; + queryParams = a.Payload.query; + } + + const url = PageIDs[pageID]; + if (!!url) { + return this.router.navigate([url], { + queryParams, + }) + } + return Promise.reject('not yet supported'); + }, + "ui": (a: ActionHandler) => { + return a.Run(a); + }, + "netquery": (a: NetqueryAction) => { + return this.router.navigate(['/monitor'], { + queryParams: { + q: a.Payload, + } + }) + }, + "call-webhook": (a: WebhookAction) => { + let method = a.Payload.Method; + if (method === '') { + if (a.Payload.Payload !== undefined && a.Payload.Payload !== null) { + method = 'PUT' + } else { + method = 'POST' + } + } + let req = this.http.request( + method, + `${environment.httpAPI}/v1/${a.Payload.URL}`, + { + body: a.Payload.Payload, + observe: 'response', + responseType: 'arraybuffer', + } + ) + return new Promise((resolve, reject) => { + const observer = this.actionIndicator.httpObserver(); + req.subscribe({ + next: res => { + if (a.Payload.ResultAction === 'display') { + if (!!observer?.next) { + observer.next(res) + } + } + resolve(res); + }, + error: err => { + if (!!observer?.error) { + observer.error(err); + } + reject(err); + }, + }) + }) + } + }; + + // For testing purposes only + VirtualNotification = VirtualNotification; + + /** A map of virtual notifications */ + private _virtualNotifications = new Map>(); + + /* Emits all virtual notifications whenever they change */ + private _virtualNotificationChange = new BehaviorSubject[]>([]); + + /* A copy of the static trackBy function. */ + trackBy = NotificationsService.trackBy; + + /** The prefix that all notifications have */ + readonly notificationPrefix = "notifications:all/"; + + /** new$ emits new (active) notifications as they arrive */ + readonly new$: Observable[]>; + + constructor( + private portapi: PortapiService, + private router: Router, + private http: HttpClient, + private actionIndicator: ActionIndicatorService, + ) { + this.new$ = this.watchAll().pipe( + src => this.injectVirtual(src), + map(msgs => { + return msgs.filter(msg => msg.State === NotificationState.Active || !msg.State) + }), + share({ connector: () => new BehaviorSubject[]>([]) }) + ); + } + + /** + * Inject a new virtual notification. If not configured otherwise, + * the notification is automatically removed when executed. + */ + inject(notif: VirtualNotification, { autoRemove } = { autoRemove: true }) { + this._virtualNotifications.set(notif.EventID, notif); + this._virtualNotificationChange.next( + Array.from(this._virtualNotifications.values()) + ) + + if (autoRemove) { + notif.executed.subscribe({ complete: () => this.deject(notif) }); + } + } + + /** Deject (remove) a virtual notification. */ + deject(notif: VirtualNotification) { + this._virtualNotifications.delete(notif.EventID); + + this._virtualNotificationChange.next( + Array.from(this._virtualNotifications.values()) + ) + } + + /** A {@link MonoOperatorFunction} that injects all virtual observables into the source. */ + private injectVirtual(obs: Observable[]>): Observable { + return combineLatest([ + obs, + this._virtualNotificationChange, + ]).pipe( + map(([real, virtual]) => { + return [ + ...real, + ...virtual, + ] + }) + ) + } + + /** + * Watch all notifications that match a query. + * + * + * @param query The query to watch. Defaulta to all notifcations + * @param opts Optional retry configuration options. + */ + watchAll(query: string = '', opts?: RetryableOpts): Observable[]> { + return this.portapi.watchAll>(this.notificationPrefix + query, opts); + } + + /** + * Query the backend for a list of notifications. In contrast + * to {@class PortAPI} query collects all results into an array + * first which makes it convenient to be used in *ngFor and + * friends. See {@function trackNotification} for a suitable track-by + * function. + * + * @param query The search query. + */ + query(query: string): Observable[]> { + return this.portapi.query>(this.notificationPrefix + query) + .pipe( + map(value => value.data), + toArray() + ) + } + + /** + * Returns the notification by ID. + * + * @param id The ID of the notification + */ + get(id: string): Observable> { + return this.portapi.get(this.notificationPrefix + id) + } + + /** + * Execute an action attached to a notification. + * + * @param n The notification object. + * @param actionId The ID of the action to execute. + */ + execute(n: Notification, action: Action): Observable; + + /** + * Execute an action attached to a notification. + * + * @param notificationId The ID of the notification. + * @param actionId The ID of the action to execute. + */ + execute(notificationId: string, action: Action): Observable; + + // overloaded implementation of execute + execute(notifOrId: Notification | string, action: Action): Observable { + const payload: Partial> = {}; + if (typeof notifOrId === 'string') { + payload.EventID = notifOrId; + } else { + payload.EventID = notifOrId.EventID; + } + + // if it's a virtual notification we should let it handle the action + // on it's own. + if (!!this._virtualNotifications.get(payload.EventID)) { + return defer(async () => { + const notif = this._virtualNotifications.get(payload.EventID!); + if (!!notif) { + notif.selectAction(action.ID); + } + }) + } + + return defer(async () => { + try { + await this.performAction(action); + + // finally, if there's an action ID, mark the notification as resolved. + if (!!action.ID) { + payload.SelectedActionID = action.ID; + const key = this.notificationPrefix + payload.EventID; + await this.portapi.update(key, payload).toPromise(); + } + } catch (err: any) { + const msg = this.actionIndicator.getErrorMessgae(err); + this.actionIndicator.error('Internal Error', 'Failed to perform action: ' + msg) + } + }) + } + + async performAction(action: Action) { + // if there's an action type defined execute the handler. + if (!!action.Type) { + const handler = this.actionHandler[action.Type] as (a: Action) => Promise; + if (!!handler) { + console.log(action); + await handler(action); + } else { + this.actionIndicator.error('Internal Error', 'Cannot handle action type ' + action.Type) + } + } + } + + /** + * Resolve a pending notification execution. + * + * @param n The notification object to resolve the pending execution. + * @param time optional The time at which the pending execution took place + */ + resolvePending(n: Notification, time?: number): Observable; + + /** + * Resolve a pending notification execution. + * + * @param n The notification ID to resolve the pending execution. + * @param time optional The time at which the pending execution took place + */ + resolvePending(n: string, time?: number): Observable; + + // overloaded implementation of resolvePending. + resolvePending(notifOrID: Notification | string, time: number = (Math.round(Date.now() / 1000))): Observable { + const payload: Partial> = {}; + if (typeof notifOrID === 'string') { + payload.EventID = notifOrID; + } else { + payload.EventID = notifOrID.EventID; + if (notifOrID.State === NotificationState.Executed) { + return throwError(`Notification ${notifOrID.EventID} already executed`); + } + } + + payload.State = NotificationState.Responded; + const key = this.notificationPrefix + payload.EventID + return this.portapi.update(key, payload); + } + + /** + * Delete a notification. + * + * @param n The notification to delete. + */ + delete(n: Notification): Observable; + + /** + * Delete a notification. + * + * @param n The notification to delete. + */ + delete(id: string): Observable; + + // overloaded implementation of delete. + delete(notifOrId: Notification | string): Observable { + return this.portapi.delete(typeof notifOrId === 'string' ? notifOrId : notifOrId.EventID); + } + + /** + * Create a new notification. + * + * @param n The notification to create. + */ + create(n: Partial>): Observable; + + /** + * Create a new notification. + * + * @param id The ID of the notificaiton. + * @param message The default message of the notificaiton. + * @param type The notification type + * @param args Additional arguments for the notification. + */ + create(id: string, message: string, type: NotificationType, args?: Partial>): Observable; + + // overloaded implementation of create. + create(notifOrId: Partial> | string, message?: string, type?: NotificationType, args?: Partial>): Observable { + if (typeof notifOrId === 'string') { + notifOrId = { + ...args, + EventID: notifOrId, + State: NotificationState.Active, + Message: message, + Type: type, + } as Notification; // it's actual Partial but that's fine. + } + + if (!notifOrId.EventID) { + return throwError(`Notification ID is required`); + } + + if (!notifOrId.Message) { + return throwError(`Notification message is required`); + } + + if (typeof notifOrId.Type !== 'number') { + return throwError(`Notification type is required`); + } + + return this.portapi.create(this.notificationPrefix + notifOrId.EventID, notifOrId); + } +} diff --git a/desktop/angular/src/app/services/notifications.types.ts b/desktop/angular/src/app/services/notifications.types.ts new file mode 100644 index 00000000..7ddf7029 --- /dev/null +++ b/desktop/angular/src/app/services/notifications.types.ts @@ -0,0 +1,205 @@ +import { getEnumKey, IntelEntity, Record } from '@safing/portmaster-api'; + +/** + * BaseAction defines a user selectable action and can + * be attached to a notification. Once selected, + * the action's ID is set as the SelectedActionID + * of the notification. + */ +export interface BaseAction { + // ID uniquely identifies the action. It's safe to + // use ID to select a localizable template to use + // instead of the Text property. If Type is set + // to None the ID may be empty, signifying that this + // action is merely to dismiss the notification. + ID: string; + // Text is the (default) text for the action label. + Text: string; +} + +export interface GenericAction extends BaseAction { + Type: ''; +} + +export interface OpenURLAction extends BaseAction { + Type: 'open-url'; + Payload: string; +} + +export interface OpenPageAction extends BaseAction { + Type: 'open-page'; + Payload: keyof typeof PageIDs | { + id: keyof typeof PageIDs, + query: { + [key: string]: string, + } + }; +} + +export interface NetqueryAction extends BaseAction { + Type: 'netquery'; + Payload: string; +} + +/** + * PageIDs holds a list of pages that can be opened using + * the OpenPageAction. + */ +export const PageIDs = { + 'monitor': '/monitor', + 'support': '/support', + 'settings': '/settings', + 'apps': '/app/overview', + 'spn': '/spn', +} + +export interface OpenSettingAction extends BaseAction { + Type: 'open-setting'; + Payload: { + Key: string; + Profile?: string; + } +} + +export interface OpenProfileAction extends BaseAction { + Type: 'open-profile'; + Payload: string; +} + +export interface WebhookAction extends BaseAction { + Type: 'call-webhook'; + Payload: { + Method: string; + URL: string; + Payload: any; + ResultAction: 'ignore' | 'display'; + } +} + +export interface ActionHandler extends BaseAction { + Type: 'ui' + Run: (vn: T) => Promise; + Payload: T; +} + +export type Action = GenericAction + | OpenURLAction + | OpenPageAction + | OpenSettingAction + | OpenProfileAction + | WebhookAction + | NetqueryAction + | ActionHandler; + +/** All action types that perform in-application routing. */ +export const routingActions = new Set([ + 'open-page', + 'open-profile', + 'open-setting' +]) + +/** + * Available types of notifications. Notification + * types are mainly for filtering and style related + * decisions. + */ +export enum NotificationType { + // Info is an informational message only. + Info = 0, + // Warning is a warning message. + Warning = 1, + // Prompt asks the user for a decision. + Prompt = 2, + // Error is for error notifications and module + // failure status. + Error = 3, +} + +export interface ConnectionPromptData { + Profile: { + ID: string; + LinkedPath: string; + Source: 'local'; + }; + Entity: IntelEntity; +} + +/** + * Returns a string representation of the notifcation type. + * + * @param val The notifcation type + */ +export function getNotificationTypeString(val: NotificationType): string { + return getEnumKey(NotificationType, val) +} + +/** + * Each notification can be in one of six different states + * that inform the client on how to handle the notification. + */ +export enum NotificationState { + // Active describes a notification that is active, no expired and, + // if actions are available, still waits for the user to select an + // action. + Active = "active", + // Responded describes a notification where the user has already + // selected which action to take but that action is still to be + // performed. + Responded = "responded", + // Responded describes a notification where the user has already + // selected which action to take but that action is still to be + // performed. + Executed = "executed", + // Invalid is a UI-only state that is used when the state of a + // notification is unknown. + Invalid = "invalid", +} + +export interface Notification extends Record { + // EventID is used to identify a specific notification. It consists of + // the module name and a per-module unique event id. + // The following format is recommended: + // : + EventID: string; + // GUID is a unique identifier for each notification instance. That is + // two notifications with the same EventID must still have unique GUIDs. + // The GUID is mainly used for system (Windows) integration and is + // automatically populated by the notification package. Average users + // don't need to care about this field. + GUID: string; + // Type is the notification type. It can be one of Info, Warning or Prompt. + Type: NotificationType; + // Message is the default message shown to the user if no localized version + // of the notification is available. Note that the message should already + // have any paramerized values replaced. Message may be formatted using + // markdown. + Message: string; + // Title holds a short notification title that quickly informs the user + // about the type of notification. + Title: string; + // Category holds an informative category for the notification and is mainly + // used for presentation purposes. + Category: string; + // EventData contains an additional payload for the notification. This payload + // may contain contextual data and may be used by a localization framework + // to populate the notification message template. + // If EventData implements sync.Locker it will be locked and unlocked together with the + // notification. Otherwise, EventData is expected to be immutable once the + // notification has been saved and handed over to the notification or database package. + EventData: T | null; + // Expires holds the unix epoch timestamp at which the notification expires + // and can be cleaned up. + // Users can safely ignore expired notifications and should handle expiry the + // same as deletion. + Expires: number; + // State describes the current state of a notification. See State for + // a list of available values and their meaning. + State: NotificationState; + // AvailableActions defines a list of actions that a user can choose from. + AvailableActions: Action[]; + // SelectedActionID is updated to match the ID of one of the AvailableActions + // based on the user selection. + SelectedActionID: string; +} + +export type ConnectionPrompt = Notification; diff --git a/desktop/angular/src/app/services/package.json b/desktop/angular/src/app/services/package.json new file mode 100644 index 00000000..a4382915 --- /dev/null +++ b/desktop/angular/src/app/services/package.json @@ -0,0 +1,3 @@ +{ + "sideEffects": false +} diff --git a/desktop/angular/src/app/services/session-data.service.ts b/desktop/angular/src/app/services/session-data.service.ts new file mode 100644 index 00000000..cb88ea91 --- /dev/null +++ b/desktop/angular/src/app/services/session-data.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +/** + * SessionDataService is used to store transient data + * that are only important as long as the application is + * being used. Those data are not presisted and are + * removed once the application is restarted. + */ +@Injectable({ + providedIn: 'root' +}) +export class SessionDataService { + private data = new Map(); + private stream = new BehaviorSubject(undefined); + + /** Set sets a value in the session data service */ + set(key: string, value: T): void { + this.data.set(key, value); + } + + get(key: string): T | null; + get(key: string, def: T): T; + + /** Get retrieves a value from the session data service */ + get(key: string, def?: any): any { + const value = this.data.get(key); + if (value !== undefined) { + return value; + } + + if (def !== undefined) { + return def; + } + return null; + } + + watch(key: string): Observable; + watch(key: string, def: T): Observable; + + /** Watch a key for changes to it's identity. */ + watch(key: string, def?: any): Observable { + return this.stream + .pipe( + map(() => this.get(key, def)), + distinctUntilChanged() + ); + } + + delete(key: string): T | null { + let value = this.get(key); + if (value !== null) { + this.data.delete(key); + } + return value; + } + + save(id: string, model: M, keys: K[]) { + let copy: Partial = {}; + keys.forEach(key => copy[key] = model[key]); + this.set(id, copy); + } + + restore(id: string, model: M) { + let copy: Partial | null = this.get(id); + if (copy === null) { + return; + } + Object.assign(model, copy); + } +} diff --git a/desktop/angular/src/app/services/status.service.spec.ts b/desktop/angular/src/app/services/status.service.spec.ts new file mode 100644 index 00000000..34ce8a6f --- /dev/null +++ b/desktop/angular/src/app/services/status.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { StatusService } from './status.service'; + +describe('StatusService', () => { + let service: StatusService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(StatusService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/desktop/angular/src/app/services/status.service.ts b/desktop/angular/src/app/services/status.service.ts new file mode 100644 index 00000000..c52b73c7 --- /dev/null +++ b/desktop/angular/src/app/services/status.service.ts @@ -0,0 +1,95 @@ +import { Injectable, TrackByFunction } from '@angular/core'; +import { PortapiService, RetryableOpts, SecurityLevel, WatchOpts, trackById } from '@safing/portmaster-api'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, map, repeat, share, toArray } from 'rxjs/operators'; +import { CoreStatus, Subsystem, VersionStatus } from './status.types'; + +@Injectable({ + providedIn: 'root' +}) +export class StatusService { + /** + * A {@link TrackByFunction} from tracking subsystems. + */ + static trackSubsystem: TrackByFunction = trackById; + readonly trackSubsystem = StatusService.trackSubsystem; + + readonly statusPrefix = "runtime:" + readonly subsystemPrefix = this.statusPrefix + "subsystems/" + + /** + * status$ watches the global core status. It's mutlicasted using a BehaviorSubject so new + * subscribers will automatically get the latest version while only one subscription + * to the backend is held. + */ + readonly status$: Observable = this.portapi.qsub(`runtime:system/status`) + .pipe( + repeat({ delay: 2000 }), + map(reply => reply.data), + share({ connector: () => new BehaviorSubject(null) }), + filter(value => value !== null), + ) as Observable; // we filtered out the null values but we cannot make that typed with RxJS. + + constructor(private portapi: PortapiService) { } + + /** Returns the currently available versions for all resources. */ + getVersions(): Observable { + return this.portapi.get('core:status/versions') + } + + /** + * Selectes a new security level. SecurityLevel.Off means that + * the auto-pilot should take over. + * + * @param securityLevel The security level to select + */ + selectLevel(securityLevel: SecurityLevel): Observable { + return this.portapi.update(`runtime:system/security-level`, { + SelectedSecurityLevel: securityLevel, + }); + } + + + /** + * Loads the current status of a subsystem. + * + * @param name The ID of the subsystem + */ + getSubsystemStatus(id: string): Observable { + return this.portapi.get(this.subsystemPrefix + id); + } + + /** + * Loads the current status of all subsystems matching idPrefix. + * If idPrefix is an empty string all subsystems are returned. + * + * @param idPrefix An optional ID prefix to limit the returned subsystems + */ + querySubsystem(idPrefix: string = ''): Observable { + return this.portapi.query(this.subsystemPrefix + idPrefix) + .pipe( + map(reply => reply.data), + toArray(), + ) + } + + /** + * Watch a subsystem for changes. Completes when the subsystem is + * deleted. See {@method PortAPI.watch} for more information. + * + * @param id The ID of the subsystem to watch. + * @param opts Additional options for portapi.watch(). + */ + watchSubsystem(id: string, opts?: WatchOpts): Observable { + return this.portapi.watch(this.subsystemPrefix + id, opts); + } + + /** + * Watch for subsystem changes + * + * @param opts Additional options for portapi.sub(). + */ + watchSubsystems(opts?: RetryableOpts): Observable { + return this.portapi.watchAll(this.subsystemPrefix, opts); + } +} diff --git a/desktop/angular/src/app/services/status.types.ts b/desktop/angular/src/app/services/status.types.ts new file mode 100644 index 00000000..f5188366 --- /dev/null +++ b/desktop/angular/src/app/services/status.types.ts @@ -0,0 +1,132 @@ +import { getEnumKey, Record, ReleaseLevel, SecurityLevel } from '@safing/portmaster-api'; + +export interface CaptivePortal { + URL: string; + IP: string; + Domain: string; +} + +export enum ModuleStatus { + Off = 0, + Error = 1, + Warning = 2, + Operational = 3 +} + +/** + * Returns a string represetnation of the module status. + * + * @param stat The module status to translate + */ +export function getModuleStatusString(stat: ModuleStatus): string { + return getEnumKey(ModuleStatus, stat) +} + +export enum OnlineStatus { + Unknown = 0, + Offline = 1, + Limited = 2, // local network only, + Portal = 3, + SemiOnline = 4, + Online = 5, +} + +/** + * Converts a online status value to a string. + * + * @param stat The online status value to convert + */ +export function getOnlineStatusString(stat: OnlineStatus): string { + return getEnumKey(OnlineStatus, stat) +} + +export interface Threat { + ID: string; + Name: string; + Description: string; + AdditionalData: T; + MitigationLevel: SecurityLevel; + Started: number; + Ended: number; +} + +export interface CoreStatus extends Record { + ActiveSecurityLevel: SecurityLevel; + SelectedSecurityLevel: SecurityLevel; + ThreatMitigationLevel: SecurityLevel; + OnlineStatus: OnlineStatus; + Threats: Threat[]; + CaptivePortal: CaptivePortal; +} + +export enum FailureStatus { + Operational = 0, + Hint = 1, + Warning = 2, + Error = 3 +} + +/** + * Returns a string representation of a failure status value. + * + * @param stat The failure status value. + */ +export function getFailureStatusString(stat: FailureStatus): string { + return getEnumKey(FailureStatus, stat) +} + +export interface Module { + Enabled: boolean; + FailureID: string; + FailureMsg: string; + FailureStatus: FailureStatus; + Name: string; + Status: ModuleStatus; +} + +export interface Subsystem extends Record { + ConfigKeySpace: string; + Description: string; + ExpertiseLevel: string; + FailureStatus: FailureStatus; + ID: string; + Modules: Module[]; + Name: string; + ReleaseLevel: ReleaseLevel; + ToggleOptionKey: string; +} + +export interface CoreVersion { + BuildDate: string; + BuildHost: string; + BuildOptions: string; + BuildSource: string; + BuildUser: string; + Commit: string; + License: string; + Name: string; + Version: string; +} + +export interface ResourceVersion { + Available: boolean; + BetaRelease: boolean; + Blacklisted: boolean; + StableRelease: boolean; + VersionNumber: string; +} + +export interface Resource { + ActiveVersion: ResourceVersion | null; + Identifier: string; + SelectedVersion: ResourceVersion; + Versions: ResourceVersion[]; +} + +export interface VersionStatus extends Record { + Channel: string; + Core: CoreVersion; + Resources: { + [key: string]: Resource + } +} diff --git a/desktop/angular/src/app/services/supporthub.service.ts b/desktop/angular/src/app/services/supporthub.service.ts new file mode 100644 index 00000000..9b8cfd7c --- /dev/null +++ b/desktop/angular/src/app/services/supporthub.service.ts @@ -0,0 +1,82 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; + +export interface SupportSection { + title: string; + body: string; +} + +export interface Issue { + title: string; + body: string; + createdAt: CreatedAt; + repository: string; + url: string; + user: string; + closed?: boolean; + labels: string[]; +} + +@Injectable({ providedIn: 'root' }) +export class SupportHubService { + constructor(private http: HttpClient) { } + + loadIssues(): Observable { + interface LoadIssuesResponse { + issues: Issue[]; + } + return this.http.get(`${environment.supportHub}/api/v1/issues`) + .pipe(map(res => res.issues.map(issue => ({ + ...issue, + createdAt: new Date(issue.createdAt), + })).reverse())); + } + + /** Uploads content under name */ + uploadText(name: string, content: string): Observable { + interface UploadResponse { + urls: { + [key: string]: string[]; + } + } + const blob = new Blob([content], { type: 'text/plain' }); + const data = new FormData(); + data.set("file", blob, name); + + return this.http.post(`${environment.supportHub}/api/v1/upload`, data) + .pipe(map(res => res.urls['file'][0])); + } + + /** Create github issue */ + createIssue(repo: string, preset: string, title: string, sections: SupportSection[], debugInfoUrl?: string, opts?: { + generateUrl: boolean, + }): Observable { + interface CreateIssueResponse { + url: string; + } + const req = { + title, + sections, + debugInfoUrl + } + let params = new HttpParams(); + if (!!opts?.generateUrl) { + params = params.set('generate-url', '') + } + return this.http.post(`${environment.supportHub}/api/v1/issues/${repo}/${preset}`, req, { params }).pipe(map(r => r.url)) + } + + createTicket(repoName: string, title: string, email: string, sections: SupportSection[], debugInfoUrl?: string): Observable { + const req = { + title, + sections, + debugInfoUrl, + email, + repoName, + } + return this.http.post(`${environment.supportHub}/api/v1/ticket`, req) + } +} diff --git a/desktop/angular/src/app/services/ui-state.service.ts b/desktop/angular/src/app/services/ui-state.service.ts new file mode 100644 index 00000000..25ad4d09 --- /dev/null +++ b/desktop/angular/src/app/services/ui-state.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from "@angular/core"; +import { PortapiService, Record } from '@safing/portmaster-api'; +import { Observable, of } from "rxjs"; +import { catchError, map, switchMap } from "rxjs/operators"; +import { SortTypes } from './../shared/network-scout/network-scout'; + +export interface UIState extends Record { + hideExitScreen?: boolean; + introScreenFinished?: boolean; + netscoutSortOrder: SortTypes; +} + +const defaultState: UIState = { + hideExitScreen: false, + introScreenFinished: false, + netscoutSortOrder: SortTypes.static +} + +@Injectable({ providedIn: 'root' }) +export class UIStateService { + constructor(private portapi: PortapiService) { } + + uiState(): Observable { + const key = 'core:ui/v1'; + return this.portapi.get(key) + .pipe( + catchError(err => of(defaultState)), + map(state => { + (Object.keys(defaultState) as (keyof UIState)[]) + .forEach(key => { + if (state[key] === undefined) { + (state as any)[key] = defaultState[key]! + } + }) + + return state + }) + ) + } + + saveState(state: UIState): Observable { + const key = 'core:ui/v1'; + return this.portapi.create(key, state); + } + + set(key: K, value: V): Observable { + return this.uiState() + .pipe( + map(state => { + state[key] = value + + return state; + }), + switchMap(newState => this.saveState(newState)) + ); + } +} diff --git a/desktop/angular/src/app/services/virtual-notification.ts b/desktop/angular/src/app/services/virtual-notification.ts new file mode 100644 index 00000000..592d886a --- /dev/null +++ b/desktop/angular/src/app/services/virtual-notification.ts @@ -0,0 +1,85 @@ +import { RecordMeta } from '@safing/portmaster-api'; +import { BehaviorSubject } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { ActionHandler, Notification, NotificationState, NotificationType } from './notifications.types'; + +export class VirtualNotification implements Notification { + readonly AvailableActions: ActionHandler[]; + readonly Category: string; + readonly EventData: T | null; + readonly GUID: string = ''; // TODO(ppacher): should we fake it? + readonly Expires: number; + readonly _meta: RecordMeta; + + get State() { + if (this.SelectedActionID === '') { + return NotificationState.Active + } + + return NotificationState.Executed + } + + get SelectedActionID() { + return this._selectedAction.getValue(); + } + + /** Emits as soon as the user selects one of the notification actions. */ + get executed() { + return this._selectedAction.pipe( + filter(action => action !== '') + ); + } + + /* Used to emit the selected action */ + private _selectedAction = new BehaviorSubject(''); + + /** + * Select and execute the action by ID. + * + * @param aid The ID of the action to execute. + */ + selectAction(aid: string) { + this._selectedAction.next(aid); + this._meta.Modified = new Date().valueOf() / 1000; + + const action = this.AvailableActions.find(a => a.ID === aid); + if (!!action) { + action.Run(action.Payload); + } + } + + constructor( + public readonly EventID: string, + public readonly Type: NotificationType, + public readonly Title: string, + public readonly Message: string, + { + AvailableActions, + EventData, + Category, + Expires, + }: { + AvailableActions?: ActionHandler[]; + EventData?: T | null; + Category?: string, + Expires?: number, + } = {} + ) { + this.AvailableActions = AvailableActions || []; + this.EventData = EventData || null; + this.Category = Category || ''; + this.Expires = Expires || 0; + + this._meta = { + Created: new Date().valueOf() / 1000, + Deleted: 0, + Expires: this.Expires, + Modified: new Date().valueOf() / 1000, + Key: `notifications:all/${EventID}`, + } + } + + dispose() { + this._selectedAction.complete(); + } +} diff --git a/desktop/angular/src/app/shared/action-indicator/action-indicator.module.ts b/desktop/angular/src/app/shared/action-indicator/action-indicator.module.ts new file mode 100644 index 00000000..2f5dafcd --- /dev/null +++ b/desktop/angular/src/app/shared/action-indicator/action-indicator.module.ts @@ -0,0 +1,13 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { IndicatorComponent } from "./indicator"; + +@NgModule({ + imports: [ + CommonModule, + ], + declarations: [ + IndicatorComponent, + ] +}) +export class ActionIndicatorModule { } diff --git a/desktop/angular/src/app/shared/action-indicator/action-indicator.service.ts b/desktop/angular/src/app/shared/action-indicator/action-indicator.service.ts new file mode 100644 index 00000000..143168e1 --- /dev/null +++ b/desktop/angular/src/app/shared/action-indicator/action-indicator.service.ts @@ -0,0 +1,284 @@ +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { Injectable, InjectionToken, Injector, isDevMode } from '@angular/core'; +import { interval, PartialObserver, Subject } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; +import { IndicatorComponent } from './indicator'; + +export interface ActionIndicator { + title: string; + message?: string; + status: 'info' | 'success' | 'error'; + timeout?: number; +} + +export const ACTION_REF = new InjectionToken('ActionIndicatorRef') +export class ActionIndicatorRef implements ActionIndicator { + title: string; + message?: string; + status: 'info' | 'success' | 'error'; + timeout?: number; + + onClose = new Subject(); + onCloseReplace = new Subject(); + + constructor(opts: ActionIndicator, private _overlayRef: OverlayRef) { + this.title = opts.title; + this.message = opts.message; + this.status = opts.status; + this.timeout = opts.timeout; + } + + close() { + this._overlayRef.detach(); + this.onClose.next(); + this.onClose.complete(); + } +} + +@Injectable({ providedIn: 'root' }) +export class ActionIndicatorService { + private _activeIndicatorRef: ActionIndicatorRef | null = null; + + constructor( + private _injector: Injector, + private overlay: Overlay, + ) { } + + /** + * Returns an observer that parses the HTTP API response + * and shows a success/error action indicator. + */ + httpObserver(successTitle?: string, errorTitle?: string): PartialObserver> { + return { + next: resp => { + let msg = this.getErrorMessgae(resp) + if (!successTitle) { + successTitle = msg; + msg = ''; + } + this.success(successTitle || '', msg) + }, + error: err => { + let msg = this.getErrorMessgae(err); + if (!errorTitle) { + errorTitle = msg; + msg = ''; + } + this.error(errorTitle || '', msg); + } + } + } + + info(title: string, message?: string, timeout?: number) { + this.create({ + title, + message: this.ensureMessage(message), + timeout, + status: 'info' + }) + } + + error(title: string, message?: string | any, timeout?: number) { + this.create({ + title, + message: this.ensureMessage(message), + timeout, + status: 'error' + }) + } + + success(title: string, message?: string, timeout?: number) { + this.create({ + title, + message: this.ensureMessage(message), + timeout, + status: 'success' + }) + } + + /** + * Creates a new user action indicator. + * + * @param msg The action indicator message to show + */ + async create(msg: ActionIndicator) { + if (!!this._activeIndicatorRef) { + this._activeIndicatorRef.onCloseReplace.next(); + await this._activeIndicatorRef.onClose.toPromise(); + } + + const cfg = new OverlayConfig({ + scrollStrategy: this.overlay + .scrollStrategies.noop(), + positionStrategy: this.overlay + .position() + .global() + .bottom('2rem') + .left('5rem'), + }); + const overlayRef = this.overlay.create(cfg); + + const ref = new ActionIndicatorRef(msg, overlayRef); + ref.onClose.pipe(take(1)).subscribe(() => { + if (ref === this._activeIndicatorRef) { + this._activeIndicatorRef = null; + } + }) + + // close after the specified time our (or 5000 seconds). + const timeout = msg.timeout || 5000; + interval(timeout).pipe( + takeUntil(ref.onClose), + take(1), + ).subscribe(() => { + ref.close(); + }) + + const injector = this.createInjector(ref); + const portal = new ComponentPortal( + IndicatorComponent, + undefined, + injector + ); + this._activeIndicatorRef = ref; + overlayRef.attach(portal); + } + + /** + * Creates a new dependency injector that provides msg as + * ACTION_MESSAGE. + */ + private createInjector(ref: ActionIndicatorRef): Injector { + return Injector.create({ + providers: [ + { + provide: ACTION_REF, + useValue: ref, + } + ], + parent: this._injector, + }) + } + + /** + * Tries to extract a meaningful error message from msg. + */ + private ensureMessage(msg: string | any): string | undefined { + if (msg === undefined || msg === null) { + return undefined; + } + + if (msg instanceof HttpErrorResponse) { + return msg.message; + } + + if (typeof msg === 'string') { + return msg; + } + + if (typeof msg === 'object') { + if ('message' in msg) { + return msg.message; + } + if ('error' in msg) { + return this.ensureMessage(msg.error); + } + if ('toString' in msg) { + return msg.toString(); + } + } + + return JSON.stringify(msg); + } + + /** + * Coverts an untyped body received by the HTTP API to a string. + */ + private stringifyBody(body: any): string { + if (typeof body === 'string') { + return body; + } + + if (body instanceof ArrayBuffer) { + return new TextDecoder('utf-8').decode(body); + } + + if (typeof body === 'object') { + return this.ensureMessage(body) || ''; + } + console.error('unsupported body', body); + + return ''; + } + + /** + * @deprecated use the version without a typo ... + */ + getErrorMessgae(resp: HttpResponse | HttpErrorResponse | Error): string { + return this.getErrorMessage(resp) + } + + /** + * Parses a HTTP or HTTP Error response and returns a + * message that can be displayed to the user. + */ + getErrorMessage(resp: HttpResponse | HttpErrorResponse | Error): string { + try { + let body: string | null = null; + + if (typeof resp === 'string') { + return resp + } + + if (resp instanceof Error) { + return resp.message; + } + + if (resp instanceof HttpErrorResponse) { + // A client-side or network error occured. + if (resp.error instanceof Error) { + body = resp.error.message; + } else { + body = this.stringifyBody(resp.error); + } + + if (!!body) { + body = body[0].toLocaleUpperCase() + body.slice(1) + return body + } + } + + + if (resp instanceof HttpResponse) { + let msg = ''; + const ct = resp.headers.get('content-type') || ''; + + body = this.stringifyBody(resp.body); + + if (/application\/json/.test(ct)) { + if (!!body) { + msg = body; + } + } else if (/text\/plain/.test(ct)) { + msg = body; + } + + // Make the first letter uppercase + if (!!msg) { + msg = msg[0].toLocaleUpperCase() + msg.slice(1) + return msg; + } + } + + console.error(`Unexpected error type`, resp) + + return `Unknown error: ${resp}` + + } catch (err: any) { + console.error(err) + return `Unknown error: ${resp}` + } + } +} diff --git a/desktop/angular/src/app/shared/action-indicator/index.ts b/desktop/angular/src/app/shared/action-indicator/index.ts new file mode 100644 index 00000000..e243b7a0 --- /dev/null +++ b/desktop/angular/src/app/shared/action-indicator/index.ts @@ -0,0 +1,2 @@ +export * from './action-indicator.service'; +export * from './action-indicator.module'; diff --git a/desktop/angular/src/app/shared/action-indicator/indicator.html b/desktop/angular/src/app/shared/action-indicator/indicator.html new file mode 100644 index 00000000..5691cd1e --- /dev/null +++ b/desktop/angular/src/app/shared/action-indicator/indicator.html @@ -0,0 +1,30 @@ + +
+ + + +
+
+ + + +
+
+
+

{{ ref.title }}

+ + + + + +
+ + {{ ref.message }} + +
+
diff --git a/desktop/angular/src/app/shared/action-indicator/indicator.scss b/desktop/angular/src/app/shared/action-indicator/indicator.scss new file mode 100644 index 00000000..6189798c --- /dev/null +++ b/desktop/angular/src/app/shared/action-indicator/indicator.scss @@ -0,0 +1,74 @@ +:host { + box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.75); + @apply bg-gray-200; + @apply p-4; + @apply rounded; + position: relative; + width: 20rem; + display: flex; + cursor: pointer; + border-left: 2px solid transparent; + + + .icon { + display: flex; + align-items: flex-start; + flex-shrink: 1; + margin-right: 1rem; + padding-top: 2px; + } + + &.error { + @apply border-yellow; + + .icon { + @apply text-yellow + } + } + + .indicator-content { + display: flex; + flex-direction: column; + align-items: flex-start; + + h1 { + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0; + } + + .message, + h1 { + flex-shrink: 0; + text-overflow: ellipsis; + } + + .message { + font-size: 0.7rem; + flex-grow: 1; + opacity: .5; + + span { + display: block; + height: 100%; + word-break: keep-all; + } + } + + .close-icon { + position: absolute; + top: 1rem; + right: 1rem; + opacity: .7; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + + h1~.message { + margin-top: .5rem; + } + } +} diff --git a/desktop/angular/src/app/shared/action-indicator/indicator.ts b/desktop/angular/src/app/shared/action-indicator/indicator.ts new file mode 100644 index 00000000..2f41d2c6 --- /dev/null +++ b/desktop/angular/src/app/shared/action-indicator/indicator.ts @@ -0,0 +1,78 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, Inject, OnInit } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { ActionIndicatorRef, ACTION_REF } from './action-indicator.service'; + +@Component({ + templateUrl: './indicator.html', + styleUrls: ['./indicator.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('slideIn', [ + state('void', style({ + opacity: 0, + transform: 'translateY(32px)' + })), + + state('showing', style({ + opacity: 1, + transform: 'translateY(0px)' + })), + + state('replace', style({ + transform: 'translateY(0px) rotate(-3deg)', + zIndex: -100, + })), + + transition('showing => replace', animate('10ms cubic-bezier(0, 0, 0.2, 1)')), + transition('void => *', animate('220ms cubic-bezier(0, 0, 0.2, 1)')), + + transition('showing => void', animate('220ms cubic-bezier(0, 0, 0.2, 1)', style({ + opacity: 0, + transform: 'translateX(-100%)' + }))), + + transition('replace => void', animate('220ms cubic-bezier(0, 0, 0.2, 1)', style({ + opacity: 0, + transform: 'translateY(-64px) rotate(-3deg)' + }))) + ]) + ] +}) +export class IndicatorComponent implements OnInit { + constructor( + @Inject(ACTION_REF) + public ref: ActionIndicatorRef, + public cdr: ChangeDetectorRef, + ) { } + + @HostBinding('@slideIn') + state = 'showing'; + + @HostBinding('class.error') + isError = this.ref.status === 'error'; + + @HostListener('click') + closeIndicator() { + this.ref.close(); + } + + @HostListener('@slideIn.done', ['$event']) + onAnimationDone() { + if (this.state === 'replace') { + this.ref.close(); + } + } + + ngOnInit() { + this.ref.onCloseReplace + .pipe( + takeUntil(this.ref.onClose), + ) + .subscribe(state => { + this.state = 'replace'; + this.cdr.detectChanges(); + }) + } +} + diff --git a/desktop/angular/src/app/shared/animations.ts b/desktop/angular/src/app/shared/animations.ts new file mode 100644 index 00000000..32989217 --- /dev/null +++ b/desktop/angular/src/app/shared/animations.ts @@ -0,0 +1,111 @@ +import { animate, query, stagger, style, transition, trigger } from '@angular/animations'; + +export const fadeInAnimation = trigger( + 'fadeIn', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateY(-5px)' }), + animate('120ms cubic-bezier(0, 0, 0.2, 1)', + style({ opacity: 1, transform: 'translateY(0px)' })) + ] + ), + ] +); + +export const fadeOutAnimation = trigger( + 'fadeOut', + [ + transition( + ':leave', + [ + style({ opacity: 1, transform: 'translateY(0px)' }), + animate('120ms cubic-bezier(0, 0, 0.2, 1)', + style({ opacity: 0, transform: 'translateY(-5px)' })) + ] + ), + ] +); + +export const fadeInListAnimation = trigger( + 'fadeInList', + [ + transition(':enter, * => 0, * => -1', []), + transition(':increment', [ + query(':enter', [ + style({ opacity: 0 }), + stagger(5, [ + animate('300ms ease-out', style({ opacity: 1 })), + ]), + ], { optional: true }) + ]), + ] +) + +export const moveInOutLeftAnimation = trigger( + 'moveInOutLeft', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateX(-100%)' }), + animate('.1s ease-in', + style({ opacity: 1, transform: 'translateX(0%)' })) + ] + ), + transition( + ':leave', + [ + style({ opacity: 1, 'z-index': -100 }), + animate('.1s ease-out', + style({ opacity: 0, transform: 'translateX(-100%)' })) + ] + ) + ] +) + + +export const moveInOutAnimation = trigger( + 'moveInOut', + [ + transition( + ':enter', + [ + style({ opacity: 0, transform: 'translateX(100%)' }), + animate('.2s ease-in', + style({ opacity: 1, transform: 'translateX(0%)' })) + ] + ), + transition( + ':leave', + [ + style({ opacity: 1 }), + animate('.2s ease-out', + style({ opacity: 0, transform: 'translateX(100%)' })) + ] + ) + ] +) + +export const moveInOutListAnimation = trigger( + 'moveInOutList', + [ + transition(':enter, * => 0, * => -1', []), + transition(':increment', [ + query(':enter', [ + style({ opacity: 0, transform: 'translateX(100%)' }), + stagger(50, [ + animate('200ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' })), + ]), + ], { optional: true }) + ]), + transition(':decrement', [ + query(':leave', [ + stagger(-50, [ + animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(100%)' })), + ]), + ], { optional: true }) + ]), + ] +) diff --git a/desktop/angular/src/app/shared/app-icon/app-icon-resolver.ts b/desktop/angular/src/app/shared/app-icon/app-icon-resolver.ts new file mode 100644 index 00000000..0a7547ad --- /dev/null +++ b/desktop/angular/src/app/shared/app-icon/app-icon-resolver.ts @@ -0,0 +1,118 @@ +import { Injectable, inject, isDevMode } from "@angular/core"; +import { AppProfile, AppProfileService, deepClone } from "@safing/portmaster-api"; +import { firstValueFrom, map, switchMap } from "rxjs"; +import { INTEGRATION_SERVICE, ProcessInfo } from "src/app/integration"; +import * as parseDataURL from 'data-urls'; + +export abstract class AppIconResolver { + abstract resolveIcon(profile: AppProfile): void; +} + +@Injectable() +export class DefaultIconResolver extends AppIconResolver { + private integration = inject(INTEGRATION_SERVICE); + private profileService = inject(AppProfileService); + + private pendingResolvers = new Map>(); + + resolveIcon(profile: AppProfile): void { + const key = `${profile.Source}/${profile.ID}`; + + // if there's already a promise in flight, abort. + if (this.pendingResolvers.has(key)) { + if (isDevMode()) { + console.log(`[icon:${profile.Name}] loading icon already in progress ...`) + } + + return; + } + + let promise = new Promise((resolve) => { + this.profileService + .getProcessesByProfile(profile) + .pipe( + map(processes => { + // if we there are no running processes for this profile, + // we try to find the icon based on the information stored in + // the profile. + let info: ProcessInfo[] = [{ + execPath: profile.LinkedPath, + cmdline: profile.PresentationPath, + pid: -1, + matchingPath: profile.PresentationPath, + }] + + processes?.forEach(process => { + // BUG: Portmaster sometimes runs a null entry, skip it here. + if (!process) { + return; + } + + // insert at the beginning since the process data might reveal + // better results than the profile one. + info.splice(0, 0, { + execPath: process.Path, + cmdline: process.CmdLine, + pid: process.Pid, + matchingPath: process.MatchingPath, + }) + }) + + return info; + }) + ).subscribe(async (processInfos) => { + for (const info of processInfos) { + try { + await this.loadAndSaveIcon(info, profile); + + // success, abort now + resolve(); + return; + } catch (err) { + // continue using the next one + } + } + + // we failed to find an icon, still resolve the promise here + // because nobody actually cares .... + resolve(); + }) + }); + this.pendingResolvers.set(key, promise); + + promise.finally(() => this.pendingResolvers.delete(key)); + } + + private async loadAndSaveIcon(info: ProcessInfo, profile: AppProfile): Promise { + const icon = await this.integration.getAppIcon(info); + + const dataURL = parseDataURL(icon); + if (!dataURL) { + throw new Error("invalid data url"); + } + const blob = new Blob([dataURL.body], { + type: dataURL.mimeType.essence, + }) + + const body = await blob.arrayBuffer(); + + const save$ = this.profileService + .setProfileIcon(body, blob.type) + .pipe(switchMap(result => { + // save the profile icon + profile = deepClone(profile); + profile.Icons = [ + ...(profile.Icons || []), + { + Value: result.filename, + Type: 'api', + Source: 'ui' + } + ]; + + return this.profileService.saveProfile(profile) + })); + + await firstValueFrom(save$); + } +} diff --git a/desktop/angular/src/app/shared/app-icon/app-icon.html b/desktop/angular/src/app/shared/app-icon/app-icon.html new file mode 100644 index 00000000..bc81164d --- /dev/null +++ b/desktop/angular/src/app/shared/app-icon/app-icon.html @@ -0,0 +1,9 @@ + + {{letter}} + + + + + diff --git a/desktop/angular/src/app/shared/app-icon/app-icon.module.ts b/desktop/angular/src/app/shared/app-icon/app-icon.module.ts new file mode 100644 index 00000000..939cac43 --- /dev/null +++ b/desktop/angular/src/app/shared/app-icon/app-icon.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { AppIconComponent } from "./app-icon"; +import { AppIconResolver, DefaultIconResolver } from "./app-icon-resolver"; + +@NgModule({ + imports: [ + CommonModule + ], + declarations: [ + AppIconComponent, + ], + exports: [ + AppIconComponent, + ], + providers: [ + { + provide: AppIconResolver, + useClass: DefaultIconResolver, + } + ] +}) +export class SfngAppIconModule { } diff --git a/desktop/angular/src/app/shared/app-icon/app-icon.scss b/desktop/angular/src/app/shared/app-icon/app-icon.scss new file mode 100644 index 00000000..159cce02 --- /dev/null +++ b/desktop/angular/src/app/shared/app-icon/app-icon.scss @@ -0,0 +1,28 @@ +:host { + border-radius: 50%; + user-select: none; + + height: var(--app-icon-size, 25px); + width: var(--app-icon-size, 25px); + flex-shrink: 0; + @apply mr-2; + + display: inline-flex; + justify-content: center; + align-items: center; +} + +span, +img { + @apply text-primary; + @apply font-medium; + @apply rounded-full; + text-shadow: rgba(0, 0, 0, .8) 0px 0px 1px; + + font-size: calc(var(--app-icon-size, 25px) / 6 * 4); +} + +img { + width: 100%; + height: 100%; +} diff --git a/desktop/angular/src/app/shared/app-icon/app-icon.ts b/desktop/angular/src/app/shared/app-icon/app-icon.ts new file mode 100644 index 00000000..f013f4e8 --- /dev/null +++ b/desktop/angular/src/app/shared/app-icon/app-icon.ts @@ -0,0 +1,312 @@ +import { Min } from './../../../../dist-lib/safing/portmaster-api/lib/netquery.service.d'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostBinding, + Inject, + Input, + OnDestroy, + OnInit, + SkipSelf, + inject, +} from '@angular/core'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { + AppProfileService, + PORTMASTER_HTTP_API_ENDPOINT, + PortapiService, + Record, + deepClone, +} from '@safing/portmaster-api'; +import { Subscription, map, of, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { INTEGRATION_SERVICE, ProcessInfo } from 'src/app/integration'; +import { AppIconResolver } from './app-icon-resolver'; + +// Interface that must be satisfied for the profile-input +// of app-icon. +export interface IDandName { + // ID of the profile. + ID?: string; + + // Source is the source of the profile. + Source?: string; + + // Name of the profile. + Name: string; +} + +// Some icons we don't want to show on the UI. +// Note that this works on a best effort basis and might +// start breaking with updates to the built-in icons... +const iconsToIngore = [ + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABU0lEQVRYhe2WTUrEQBCF36i4ctm4FsdTKF5AEFxL0knuILgQXAy4ELxDfgTXguAFRG/hDXKCAbtcOB3aSVenMjPRTb5NvdCE97oq3QQYGflnJlbc3T/QXxrfXF9NAGBraKPTk2Nvtey4D1l8OUiIo8ODX/Xt/cMfQCk1SAAi8upWgLquWy8rpbB7+yk2m8+mYvNWAAB4fnlt9MX5WaP397ZhCPgygCFa1IUmwJifCgB5nrMBtdbhAK6pi9QcALIs8+5c1AEOqTmwZge4EUjNiQhpmjbarcvaG4AbgcTcUhSFfwFAHMfhABxScwBIkgRA9wnwBgiOQGBORCjLkl2PoigcgB2BwNzifmi97wEOqTkRoaoqdr2zA9wIJOYWrTW785VPQR+WO2B3vdYIpBBRc9Qkp2Cw/4GVR+BjPpt23u19tUXUgU2aBzuQPz5J8oyMjGyUb9+FOUOmulVPAAAAAElFTkSuQmCC', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACLElEQVR4nO2av07DMBDGP1DFxtaFmbeg6gtUqtQZtU3yDkgMSAxIDEi8Q/8gMVdC4m1YYO0TMNQspErdOG3Md25c7rc0st3E353v7EsLKIqiKIqiKMq/5MRueHx6NoeYSCjubm82NJ8eaiISdDtX6HauKq9tWsFmF4DPr6+d1zalBshG18RpNYfJy+tW21GFgA+lK6DdboeeBwVjyvO3qx1wGGC5XO71wCYZykc8QEqCZ/cfjNs4+X64rOz3FQ/sMMDi7R2Dfg+Lt/eN9kG/tzX24rwFA8AYYGXM+nr9aQADs9mG37FWW3HsqqBhMpnsFFRGkiTOvkoD5ELLBNtIiLcdmGXZ5jP/4Pkc2i4gIb5KRl3xrnbaQSiEeN8QGI/Hzj5aDgjh+SzLaJ7P4eWAiJZ9EVoIhBA/nU695uYdAnUI4fk0TUvbXeP3gZcDhMS7CLIL1DsHyIv3DYHRaOTs44YAZD2fpik9EfIOQohn2Rch5wBZ8bPZzOObfwiBurWAtOftoqaO511jaSEgJd4FQzwgmAQlxPuGwHA4dPbJ1QICnk+ShOb5HJlaoOHLvgi/FhAUP5/P9xpbteRtyDlA1vN2UVPH8+K7gJR45/MI4gHyK7HYxANsA7BuVvkcnniAXAtIwxYPRPTboIR4IBIDMMSL7wIhYZbF0RmgsS9EQtDY1+L5r7esCUrGvA3xHBCfeIBkgBjEi+0CMYsHHDmg7N9UiqIoiqIoiqIcFT++NKIXgDvowAAAAABJRU5ErkJggg==', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAABqUlEQVRYhe2XP2rDMBSHfymhU0dDD5BbJOQCgUDmEv+7Q6FDoUOgQ6F3cJxC50Agt+nSrD5BBr8OqVyrtfWkl8ShoG+SjJE+/95DwoDH4/nf9NTg+eWVLinym8eH+x4AXF1i8/FoiPFoaBwr+p3bAfjc7dixQhNMw7szatmTvb1XY00wCILOZYjIONcEi6JoXSgIAlw/fYhF9ouBsxzQ0IPrzRaz6QTrzbZ6NptOqvHtTR8EQklAWQIl4WdOQEkEqsaHefm9b5Zl7IfEcWwWVDJ1Ke0rHeXqmaRpeljDIrlWQQ5XufreNglGUWQW5EoslQOAJEm0uagHuRJL5YgIy+Wycc06bIIcEjmFStCUnPGYASxKLJQDYJVgGIZmQZsSS+SAv0eIKblWQQ6pHBEhz3N2fTZBrsQSOYVK0JQc24N2JXaXA2CV4Hw+NwtySOUA/QixvU1kPSiQIyKsViv2vaMTlMgpoihik2N7kEMqB6AxwXpiVlfduSAi7Qix7cGL/DS5XHWdC7rIAY4l3i8GTk1+zLsKpwS7lnMS7ErOeMzU/0c9Ho/nNHwBdUH2gB9vJRsAAAAASUVORK5CYII=', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAByElEQVRYhe1WQUoDQRCsmSh4CAreo3/w4CdE8JirLzCKGhRERPBqfISQx3j0BcaDJxHNRWS7PWRmtmdmJ9mNiSuYOmyYbOiqruoeAizw36G6p0e3WulOHeTE1NO/Qb6zu1f4qZXuqLPuMV9d38xbQyEuL86ha2EWWJKHfr+P4XAIAGg2m2i32wCA7fsXPH9kABjMgHkADP87cW6tNvCwvzG2biRAvpAYvH+54mCAmUcvmI0Yq4nM74DBG02sGwlIgqigS/ZEgdkcrSAuVbpUBEyjTiP7JSkDzKZrdo+xdSMBKas4y4K8befSiVxcLnR83UhACtYBV9TOgbBbOX4TF2YZQZY5Yi9/MYwkXQjy/3EEtjp7LgQzAeOUVSo0zCACcgOnwjUEC2LE7kxApS0AGFRgP4vZ8M5VBaQjoNGKuQ20Q2ney8Gr0H0kIAU7hK4zYiPCJxtFZYRMIyAdAQWrFgyicMSfj4oCkheRmQFyIoq2IRcy9T2QhNmCfN/FVcwMBSWu4XlsQUZe5tZmZW0HBXGU4o4FpCJorS3j6fXTEOVdUrgNApvrK9UFpPB4vlWq2DSo/S+Z6p4c9rRuHNRBTsR3dfAu8LfwDdGgu25Uax8RAAAAAElFTkSuQmCC', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAByUlEQVRYhe1WQUoDQRCs2UTwEBS8R//gwU+I4DFXX2AENRgQEcGr8RFCHuPRFxgPnkQ0F9Ht9rAzsz0zO8luTFzB1GHDZENXdVX3EGCJ/w7VO+3eJKrZrYOc+GuQ/Ab57t5+4Weiml111jvmy6vrRWsoxMV5H0ktzAJNeRgOhxiPxwCAVquFTqcDANi5e8bTewqAwQzoB8BwvxPn9loD9webE+sGAuQLidHbpy0OBpg5e8GsxRhNpH8HjF5pat1AQBREBV2yIwrM+mgEcanSpSJgyjoN7JekDDDrrtk+JtYNBMSs4jT18jadSydycbnQyXUDATEYB2xRMwfCbmX5dVyYZwRpaomd/MUwknTBy//HEZjq7LjgzQS0U0ap0DCHCMgOnPLXECyIEbozBZW2AGBQgf0sZsM5VxUQj4CyFbMbaIZSv5eDV6H7QEAMZghtZ8RahEuWRaWFzCIgHgF5q+YNonDEnY+KAqIXkZ4BsiKKtiEXMvM9EIXegnzfxVXMDAUlruFFbEFKTubGZmVsB3lxlOIOBcQiaK+v4PHlQxPlXZK/DQJbG6vVBcTw0N8uVWwW1P6XTPVOjgZJ0jisg5yIb+vgXeJv4RvrxrtwzfCUqAAAAABJRU5ErkJggg==', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADo0lEQVRYhe2Wu28cVRTGf+fcuzZeY0NCUqTgD3C8mzRU0KDQEgmqoBSGhoKWAlFEKYyQAAkrTRRqOpCQkOhAkUCio4D4IYSAzkkKB+wQbLy7c8+hmNmd2WecDQoFfNJodGfn7vc4Z84M/I9/GfLeB1cutdqH7zxSUli9fOntd4EsmrVXL1xcodVqAf6PEl37+AveWDk/dP78s08vA1eBvSgSDnd3bs49DJGKICIg+dod3J3XXn6Ogz9+49WXnu07F1gA9mOWJRqNBrNRcJ8mAQF8ZHYyuBYhI/DlV9cBAqARnBAj2agdjwARoBaETnK+/eY7NMwfaaPZPueefwaA73+4MfKeM80GAC+8+QkA19cukCQOC+ga1zDPR1//jIgjWhzBEQWNBupoNESdldNn2dm5w/FjT/SIpkEcvLAwX0PUQRwNXQGOBCvXoVpxZ31jc2ICEwWY+1y19AvzEQr3GgAtiLUUo8F690tB5DhC3sgiw800f2p/fAJ/tTtoyMOo1yOqnscdnINOIqNDO+vQbrdwMTRWEnBhfXNyAvOn9qmfOBgvwKxwC9TnAskTN3f32PnzHi1robEbv6HFUVGQJ+AOIvkQgL4U6icOqC9OSKCKu4cH/HT7Nh3P0GiEWkEcc+LBEhylB+qL+ywe+328gGrFNre3kWiE6EjsOi5EqPVS6EGEZrOJW0JVR5KMIy8TqCjQmlUcl7GLlvGrlgLcYWNzY2ICk1CUoFSgtdRPHAwtYteQeimUCuDsmebEMX7l3Pv3E1BCY+lUgqNaFZJ663ID3Fh/6ARKhFrqNVq15lVy1dRP1FjGRaZ6lQwnEKqkw+Si/QLMATwnHxhA7o65k2UJM0NwanOP30dATAPkhmjlmuYiuhCcja0fR7prNhqA4W5Fjwz3ydBTEGLZaKoV99p13y8AnGZjeeT4dfd8LrnnCYyoUQTQQsGtW7/y+tPnR7oZxPb2LywvncRd2dzaGnnP6aUlzBLJvKt1tIAsObUAF195kZ2dO0cSsLx0EgAz6yWQO3aSGeZOJ8swS5gNj+c+AeYwE4QgxlPHF6nNzkBKpGQ4EGMAnSksOGCA41nisJP/eTfuVIjAHQRCCITiPaPjBAC0kwMKMkvW7vuJTgZQffSkOBRCLqeL0cN4PKLA6trah2/FGB97wL05oSohKCEEzMBSRkpp4gf+3d3dq+SOTIAZ4Enyz+QwjYgpkIB7wF6RIxGo8eAJTgsDOpB/jP+38TcKdstukjAxWQAAAABJRU5ErkJggg==', +]; + +const profilesToIgnore = ['local/_unidentified', 'local/_unsolicited']; + +@Component({ + selector: 'app-icon', + templateUrl: './app-icon.html', + styleUrls: ['./app-icon.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppIconComponent implements OnInit, OnDestroy { + private sub = Subscription.EMPTY; + private initDone = false; + + private resovler = inject(AppIconResolver); + + /** @private The data-URL for the app-icon if available */ + src: SafeUrl | string = ''; + + /** The profile for which to show the app-icon */ + @Input() + set profile(p: IDandName | null | undefined | string) { + if (typeof p === 'string') { + const parts = p.split("/") + p = { + Source: parts[0], + ID: parts[1], + Name: '', + } + } + + if (!!this._profile && !!p && this._profile.ID === p.ID) { + // skip if this is the same profile + return; + } + + this._profile = p || null; + + if (this.initDone) { + this.updateView(); + } + } + get profile(): IDandName | null | undefined { + return this._profile; + } + private _profile: IDandName | null = null; + + /** isIgnoredProfile is set to true if the profile is part of profilesToIgnore */ + isIgnoredProfile = false; + + /** If not icon is available, this holds the first - uppercased - letter of the app - name */ + letter: string = ''; + + /** @private The background color of the component, based on icon availability and generated by ID */ + @HostBinding('style.background-color') + color: string = 'var(--text-tertiary)'; + + constructor( + private profileService: AppProfileService, + private changeDetectorRef: ChangeDetectorRef, + private portapi: PortapiService, + // @HostBinding() is not evaluated in our change-detection run but rather + // checked by the parent component during updateRenderer. + // Since we want the background color to change immediately after we set the + // src path we need to tell the parent (which ever it is) to update as wel. + @SkipSelf() private parentCdr: ChangeDetectorRef, + private sanitzier: DomSanitizer, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string + ) { } + + /** Updates the view of the app-icon and tries to find the actual application icon */ + private requestedAnimationFrame: number | null = null; + private updateView(skipIcon = false) { + if (this.requestedAnimationFrame !== null) { + cancelAnimationFrame(this.requestedAnimationFrame); + } + + this.requestedAnimationFrame = requestAnimationFrame(() => { + this.__updateView(); + }) + } + + ngOnInit(): void { + this.updateView(); + this.initDone = true; + } + + private __updateView(skipIcon = false) { + this.requestedAnimationFrame = null; + + const p = this.profile; + const sourceAndId = this.getIDAndSource(); + + if (!!p && sourceAndId !== null) { + let idx = 0; + for (let i = 0; i < (p.ID || p.Name).length; i++) { + idx += (p.ID || p.Name).charCodeAt(i); + } + + const combinedID = `${sourceAndId[0]}/${sourceAndId[1]}`; + this.isIgnoredProfile = profilesToIgnore.includes(combinedID); + + this.updateLetter(p); + + if (!this.isIgnoredProfile) { + this.color = AppColors[idx % AppColors.length]; + } else { + this.color = 'transparent'; + } + + if (!skipIcon) { + this.tryGetSystemIcon(p); + } + + } else { + this.isIgnoredProfile = false; + this.color = 'var(--text-tertiary)'; + } + + this.changeDetectorRef.markForCheck(); + this.parentCdr.markForCheck(); + } + + private updateLetter(p: IDandName) { + if (p.Name !== '') { + if (p.Name[0] === '<') { + // we might get the name with search-highlighting which + // will then include tags. If the first character is a < + // make sure to strip all HTML tags before getting [0]. + this.letter = p.Name.replace( + /( |<([^>]+)>)/gi, + '' + )[0].toLocaleUpperCase(); + } else { + this.letter = p.Name[0]; + } + + this.letter = this.letter.toLocaleUpperCase(); + } else { + this.letter = '?'; + } + } + + getIDAndSource(): [string, string] | null { + if (!this.profile) { + return null; + } + + let id = this.profile.ID; + if (!id) { + return null; + } + + // if there's a source ID only holds the profile ID + if (!!this.profile.Source) { + return [this.profile.Source, id]; + } + + // otherwise, ID likely contains the source + let [source, ...rest] = id.split('/'); + if (rest.length > 0) { + return [source, rest.join('/')]; + } + + // id does not contain a forward-slash so we + // assume the source is local + return ['local', id]; + } + + /** + * Tries to get the application icon form the system. + * Requires the app to be running in the electron wrapper. + */ + private tryGetSystemIcon(p: IDandName) { + const sourceAndId = this.getIDAndSource(); + if (sourceAndId === null) { + return; + } + + this.sub.unsubscribe(); + + this.sub = this.profileService + .watchAppProfile(sourceAndId[0], sourceAndId[1]) + .pipe( + switchMap((profile) => { + this.updateLetter(profile); + + if (!!profile.Icons?.length) { + const firstIcon = profile.Icons[0]; + + console.log(`profile ${profile.Name} has icon of from source ${firstIcon.Source} stored in ${firstIcon.Type}`) + + switch (firstIcon.Type) { + case 'database': + return this.portapi + .get(firstIcon.Value) + .pipe( + map((result) => { + return result.iconData; + }) + ); + + case 'api': + return of(`${this.httpAPI}/v1/profile/icon/${firstIcon.Value}`); + + default: + console.error(`Icon type ${firstIcon.Type} not yet supported`); + } + } + + this.resovler.resolveIcon(profile); + + // return an empty icon here. If the resolver manages to find an icon + // the profle will get updated and we'll run again here. + return of(''); + }) + ) + .subscribe({ + next: (icon) => { + if (iconsToIngore.some((i) => i === icon)) { + icon = ''; + } + if (icon !== '') { + this.src = this.sanitzier.bypassSecurityTrustUrl(icon); + this.color = 'unset'; + } else { + this.src = ''; + this.color = + this.color === 'unset' ? 'var(--text-tertiary)' : this.color; + } + this.changeDetectorRef.detectChanges(); + this.parentCdr.markForCheck(); + }, + error: (err) => console.error(err), + }); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } +} + +export const AppColors: string[] = [ + 'rgba(244, 67, 54, .7)', + 'rgba(233, 30, 99, .7)', + 'rgba(156, 39, 176, .7)', + 'rgba(103, 58, 183, .7)', + 'rgba(63, 81, 181, .7)', + 'rgba(33, 150, 243, .7)', + 'rgba(3, 169, 244, .7)', + 'rgba(0, 188, 212, .7)', + 'rgba(0, 150, 136, .7)', + 'rgba(76, 175, 80, .7)', + 'rgba(139, 195, 74, .7)', + 'rgba(205, 220, 57, .7)', + 'rgba(255, 235, 59, .7)', + 'rgba(255, 193, 7, .7)', + 'rgba(255, 152, 0, .7)', + 'rgba(255, 87, 34, .7)', + 'rgba(121, 85, 72, .7)', + 'rgba(158, 158, 158, .7)', + 'rgba(96, 125, 139, .7)', +]; diff --git a/desktop/angular/src/app/shared/app-icon/index.ts b/desktop/angular/src/app/shared/app-icon/index.ts new file mode 100644 index 00000000..90675ee7 --- /dev/null +++ b/desktop/angular/src/app/shared/app-icon/index.ts @@ -0,0 +1,2 @@ +export { AppIconComponent } from './app-icon'; +export { SfngAppIconModule } from './app-icon.module'; diff --git a/desktop/angular/src/app/shared/config/basic-setting/basic-setting.html b/desktop/angular/src/app/shared/config/basic-setting/basic-setting.html new file mode 100644 index 00000000..11e864a8 --- /dev/null +++ b/desktop/angular/src/app/shared/config/basic-setting/basic-setting.html @@ -0,0 +1,69 @@ + + + + + + + + + {{opt.Name}} + + + + + + {{opt}} + + + + + + +
+ + + {{ unit }} + +
+
+
+ + +
+ + + {{ unit }} + +
+ + + + + + + + + + +
+
diff --git a/desktop/angular/src/app/shared/config/basic-setting/basic-setting.scss b/desktop/angular/src/app/shared/config/basic-setting/basic-setting.scss new file mode 100644 index 00000000..0bb87370 --- /dev/null +++ b/desktop/angular/src/app/shared/config/basic-setting/basic-setting.scss @@ -0,0 +1,28 @@ +label { + @apply text-sm; +} + +input[type="checkbox"] { + float: right; + user-select: none; +} + +.input-container { + display: block; + position: relative; + font-size: 0.75rem; + + input { + font-size: inherit; + } + + .suffix { + user-select: none; + position: absolute; + left: 0; + top: calc(50% - 0.55rem); + padding-left: 0.3rem; + color: #aaa; + font: inherit; + } +} diff --git a/desktop/angular/src/app/shared/config/basic-setting/basic-setting.ts b/desktop/angular/src/app/shared/config/basic-setting/basic-setting.ts new file mode 100644 index 00000000..82433fb8 --- /dev/null +++ b/desktop/angular/src/app/shared/config/basic-setting/basic-setting.ts @@ -0,0 +1,333 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DOCUMENT } from '@angular/common'; +import { AfterViewChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, Inject, Input, Output, ViewChild } from '@angular/core'; +import { AbstractControl, ControlValueAccessor, NgModel, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms'; +import { BaseSetting, ExternalOptionHint, OptionType, parseSupportedValues, SettingValueType, WellKnown } from '@safing/portmaster-api'; + +@Component({ + selector: 'app-basic-setting', + templateUrl: './basic-setting.html', + styleUrls: ['./basic-setting.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => BasicSettingComponent), + }, + { + provide: NG_VALIDATORS, + multi: true, + useExisting: forwardRef(() => BasicSettingComponent), + } + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BasicSettingComponent> implements ControlValueAccessor, Validator, AfterViewChecked { + /** @private template-access to all external option hits */ + readonly optionHints = ExternalOptionHint; + + /** @private template-access to parseSupportedValues */ + readonly parseSupportedValues = parseSupportedValues; + + @ViewChild('suffixElement', { static: false, read: ElementRef }) + suffixElement?: ElementRef; + + /** Cached canvas element used by getTextWidth */ + private cachedCanvas?: HTMLCanvasElement; + + /** Returns the value of external-option hint annotation */ + externalOptType(opt: S): ExternalOptionHint | null { + return opt.Annotations?.["safing/portbase:ui:display-hint"] || null; + } + + /** Whether or not the input should be currently disabled. */ + @Input() + set disabled(v: any) { + const disabled = coerceBooleanProperty(v); + this.setDisabledState(disabled); + } + get disabled() { + return this._disabled; + } + + /** The setting to display */ + @Input() + setting: S | null = null; + + /** Emits when the user activates focus on this component */ + @Output() + blured = new EventEmitter(); + + /** @private The ngModel in our view used to display the value */ + @ViewChild(NgModel) + model: NgModel | null = null; + + /** The unit of the setting */ + get unit() { + if (!this.setting) { + return ''; + } + return this.setting.Annotations[WellKnown.Unit] || ''; + } + + /** + * Holds the value as it is presented to the user. + * That is, a JSON encoded object or array is dumped as a + * JSON string. Strings, numbers and booleans are presented + * as they are. + */ + _value: string | number | boolean = ""; + + /** + * Describes the type of the original settings value + * as passed to writeValue(). + * This may be anything that can be returned from `typeof v`. + * If set to "string", "number" or "boolean" then _value is emitted + * as it is. + * If it's set anything else (like "object") than _value is JSON.parse`d + * before being emitted. + */ + _type: string = ''; + + /* Returns true if the current _type and _value is managed as JSON */ + get isJSON(): boolean { + return this._type !== 'string' + && this._type !== 'number' + && this._type !== 'boolean' + } + + /* + * _onChange is set using registerOnChange by @angular/forms + * and satisfies the ControlValueAccessor. + */ + private _onChange: (_: SettingValueType) => void = () => { }; + + /* _onTouch is set using registerOnTouched by @angular/forms + * and satisfies the ControlValueAccessor. + */ + private _onTouch: () => void = () => { }; + + private _onValidatorChange: () => void = () => { }; + + /* Whether or not the input field is disabled. Set by setDisabledState + * from @angular/forms + */ + _disabled: boolean = false; + private _valid: boolean = true; + + // We are using ChangeDetectionStrategy.OnPush so angular does not + // update ourself when writeValue or setDisabledState is called. + // Using the changeDetectorRef we can take care of that ourself. + constructor( + @Inject(DOCUMENT) private document: Document, + private _changeDetectorRef: ChangeDetectorRef + ) { } + + ngAfterViewChecked() { + // update the suffix position everytime angular has + // checked our view for changes. + this.updateUnitSuffixPosition(); + } + + /** + * Sets the user-presented value and emits a change. + * Used by our view. Not meant to be used from outside! + * Use writeValue instead. + * @private + * + * @param value The value to set + */ + setInternalValue(value: string | number | boolean) { + let toEmit: any = value; + try { + if (!this.isJSON) { + toEmit = value; + } else { + toEmit = JSON.parse(value as string); + } + } catch (err) { + this._valid = false; + this._onValidatorChange(); + return; + } + + this._valid = true; + this._value = value; + this._onChange(toEmit); + this.updateUnitSuffixPosition(); + } + + /** + * Updates the position of the value's unit suffix element + */ + private updateUnitSuffixPosition() { + if (!!this.unit && !!this.suffixElement) { + const input = this.suffixElement.nativeElement.previousSibling! as HTMLInputElement; + const style = window.getComputedStyle(input); + let paddingleft = parseInt(style.paddingLeft.slice(0, -2)) + // we need to use `input.value` instead of `value` as we need to + // get preceding zeros of the number input as well, while still + // using the value as a fallback. + let value = input.value || (this._value as string); + const width = this.getTextWidth(value, style.font) + paddingleft; + this.suffixElement.nativeElement.style.left = `${width}px`; + } + } + + /** + * Validates if "value" matches the settings requirements. + * It satisfies the NG_VALIDATORS interface and validates the + * value for THIS component. + * + * @param param0 The AbstractControl to validate + */ + validate({ value }: AbstractControl): ValidationErrors | null { + if (!this._valid) { + return { + jsonParseError: true + } + } + + if (this._type === 'string' || value === null) { + if (!!this.setting?.DefaultValue && !value) { + return { + required: true, + } + } + } + + if (!!this.setting?.ValidationRegex) { + const re = new RegExp(this.setting.ValidationRegex); + + if (!this.isJSON) { + if (!re.test(`${value}`)) { + return { + pattern: `"${value}"` + } + } + } else { + if (!Array.isArray(value)) { + return { + invalidType: true + } + } + const invalidLines = value.filter(v => !re.test(v)); + if (invalidLines.length) { + return { + pattern: invalidLines + } + } + } + } + + return null; + } + + /** + * Writes a new value and satisfies the ControlValueAccessor + * + * @param v The new value to write + */ + writeValue(v: SettingValueType) { + // the following is a super ugly work-around for the migration + // from security-settings to booleans. + // + // In order to not mess and hide an actual portmaster issue + // we only convert v to a boolean if it's a number value and marked as a security setting. + // In all other cases we don't mangle it. + // + // TODO(ppacher): Remove in v1.8? + // BOM + if (this.setting?.OptType === OptionType.Bool && this.setting?.Annotations[WellKnown.DisplayHint] === ExternalOptionHint.SecurityLevel) { + if (typeof v === 'number') { + (v as any) = v === 7; + } + } + // EOM + + let t = typeof v; + this._type = t; + + if (this.isJSON) { + this._value = JSON.stringify(v, undefined, 2); + } else { + this._value = v; + } + + this.updateUnitSuffixPosition(); + this._changeDetectorRef.markForCheck(); + } + + registerOnValidatorChange(fn: () => void) { + this._onValidatorChange = fn; + } + + /** + * Registers the onChange function requred by the + * ControlValueAccessor + * + * @param fn The fn to register + */ + registerOnChange(fn: (_: SettingValueType) => void) { + this._onChange = fn; + } + + /** + * @private + * Called when the input-component used for the setting is touched/focused. + */ + touched() { + this._onTouch(); + this.blured.next(); + } + + /** + * Registers the onTouch function requred by the + * ControlValueAccessor + * + * @param fn The fn to register + */ + registerOnTouched(fn: () => void) { + this._onTouch = fn; + } + + /** + * Enable or disable the component. Required for the + * ControlValueAccessor. + * + * @param disable Whether or not the component is disabled + */ + setDisabledState(disable: boolean) { + this._disabled = disable; + this._changeDetectorRef.markForCheck(); + } + + /** + * @private + * Returns the number of lines in value. If value is not + * a string 1 is returned. + */ + lineCount(value: string | number | boolean) { + if (typeof value === 'string') { + return value.split('\n').length + } + return 1 + } + + /** + * Calculates the amount of pixel a text requires when being rendered. + * It uses canvas.measureText on a dummy (no attached) element + * + * @param text The text that would be rendered + * @param font The CSS font descriptor that would be used for the text + */ + private getTextWidth(text: string, font: string): number { + let canvas = this.cachedCanvas || this.document.createElement('canvas'); + this.cachedCanvas = canvas; + + let context = canvas.getContext("2d")!; + context.font = font; + let metrics = context.measureText(text); + return metrics.width; + } +} diff --git a/desktop/angular/src/app/shared/config/basic-setting/index.ts b/desktop/angular/src/app/shared/config/basic-setting/index.ts new file mode 100644 index 00000000..ec1ff492 --- /dev/null +++ b/desktop/angular/src/app/shared/config/basic-setting/index.ts @@ -0,0 +1 @@ +export * from './basic-setting'; diff --git a/desktop/angular/src/app/shared/config/config-settings.html b/desktop/angular/src/app/shared/config/config-settings.html new file mode 100644 index 00000000..f6c9253e --- /dev/null +++ b/desktop/angular/src/app/shared/config/config-settings.html @@ -0,0 +1,111 @@ + + + +
+ + +
+ + + + + +

+ {{subsys.Name}} +

+ + +
+ +
+

{{cat.name}}

+ + + + + +
+ +
+ +
+ + + + +
+
+
+
+
+
+
+ + +

+ Other +

+
+ + + + +
+
+
+
+
diff --git a/desktop/angular/src/app/shared/config/config-settings.scss b/desktop/angular/src/app/shared/config/config-settings.scss new file mode 100644 index 00000000..f839c341 --- /dev/null +++ b/desktop/angular/src/app/shared/config/config-settings.scss @@ -0,0 +1,95 @@ +:host { + display: flex; + overflow: hidden; +} + + +fa-icon[icon="spinner"] { + @apply text-3xl; + display: block; + width: 100%; + text-align: center; + height: 6rem; +} + +div.settings-nav { + @apply mt-4; + flex-shrink: 0; + overflow: visible; + white-space: nowrap; + + transition: height cubic-bezier(0.25, 0.46, 0.45, 0.94) .5s; + @apply text-xs; + + + ul { + position: fixed; + + li { + @apply font-medium; + + &.separated { + margin-top: 1.25rem; + } + + } + + &>li { + @apply mb-1; + @apply text-tertiary; + + span { + cursor: pointer; + display: block; + } + + &:hover, + &.active { + @apply text-primary; + } + + &.active { + &.category:before { + content: ""; + width: 1px; + height: 1rem; + @apply bg-white block absolute; + left: 0.5rem; + } + + ul.settings { + display: inline-block; + } + } + + ul.settings { + position: unset; + @apply mt-2; + @apply ml-2; + @apply pl-3; + @apply text-xs; + @apply border-l; + @apply border-cards-tertiary; + display: none; + + li { + cursor: pointer; + margin-top: 0; + } + } + } + } +} + +.user-defined-value:before { + content: ""; + height: 1rem; + @apply bg-blue block absolute rounded-full w-1 h-1; + top: 0.45rem; + left: -1rem; +} + +.user-defined-value.category:before { + left: -2rem; + top: 0.35rem; +} diff --git a/desktop/angular/src/app/shared/config/config-settings.ts b/desktop/angular/src/app/shared/config/config-settings.ts new file mode 100644 index 00000000..49301abf --- /dev/null +++ b/desktop/angular/src/app/shared/config/config-settings.ts @@ -0,0 +1,606 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ScrollDispatcher } from '@angular/cdk/overlay'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + TrackByFunction, + ViewChildren, +} from '@angular/core'; +import { + ConfigService, + ExpertiseLevelNumber, + PortapiService, + Setting, + StringSetting, + releaseLevelFromName, +} from '@safing/portmaster-api'; +import { BehaviorSubject, Subscription, combineLatest } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { StatusService, Subsystem } from 'src/app/services'; +import { + fadeInAnimation, + fadeInListAnimation, + fadeOutAnimation, +} from 'src/app/shared/animations'; +import { FuzzySearchService } from 'src/app/shared/fuzzySearch'; +import { ExpertiseLevelOverwrite } from '../expertise/expertise-directive'; +import { SaveSettingEvent } from './generic-setting/generic-setting'; +import { ActionIndicatorService } from '../action-indicator'; +import { SfngDialogService } from '@safing/ui'; +import { + ExportConfig, + ExportDialogComponent, +} from './export-dialog/export-dialog.component'; +import { + ImportConfig, + ImportDialogComponent, +} from './import-dialog/import-dialog.component'; + +interface Category { + name: string; + settings: Setting[]; + minimumExpertise: ExpertiseLevelNumber; + collapsed: boolean; + hasUserDefinedValues: boolean; +} + +interface SubsystemWithExpertise extends Subsystem { + minimumExpertise: ExpertiseLevelNumber; + isDisabled: boolean; + hasUserDefinedValues: boolean; +} + +@Component({ + selector: 'app-settings-view', + templateUrl: './config-settings.html', + styleUrls: ['./config-settings.scss'], + animations: [fadeInAnimation, fadeOutAnimation, fadeInListAnimation], +}) +export class ConfigSettingsViewComponent + implements OnInit, OnDestroy, AfterViewInit { + subsystems: SubsystemWithExpertise[] = []; + others: Setting[] | null = null; + settings: Map = new Map(); + + /** A list of all selected settings for export */ + selectedSettings: { [key: string]: boolean } = {}; + + /** Whether or not we are currently in "export" mode */ + exportMode = false; + + activeSection = ''; + activeCategory = ''; + loading = true; + + @Input() + resetLabelText = 'Reset to system default'; + + @Input() + set compactView(v: any) { + this._compactView = coerceBooleanProperty(v); + } + get compactView() { + return this._compactView; + } + private _compactView = false; + + @Input() + set lockDefaults(v: any) { + this._lockDefaults = coerceBooleanProperty(v); + } + get lockDefaults() { + return this._lockDefaults; + } + private _lockDefaults = false; + + @Input() + set userSettingsMarker(v: any) { + this._userSettingsMarker = coerceBooleanProperty(v); + } + get userSettingsMarker() { + return this._userSettingsMarker; + } + private _userSettingsMarker = true; + + @Input() + set searchTerm(v: string) { + this.onSearch.next(v); + } + + @Input() + set availableSettings(v: Setting[]) { + this.onSettingsChange.next(v); + } + + @Input() + set scope(scope: 'global' | string) { + this._scope = scope; + } + get scope() { + return this._scope; + } + private _scope: 'global' | string = 'global'; + + @Input() + displayStackable: string | boolean = false; + + @Input() + set highlightKey(key: string | null) { + this._highlightKey = key || null; + this._scrolledToHighlighted = false; + // If we already loaded the settings then instruct the window + // to scroll the setting into the view. + if (!!key && !!this.settings && this.settings.size > 0) { + this.scrollTo(key); + this._scrolledToHighlighted = true; + } + } + get highlightKey() { + return this._highlightKey; + } + private _highlightKey: string | null = null; + private _scrolledToHighlighted = false; + + mustShowSetting: ExpertiseLevelOverwrite = ( + lvl: ExpertiseLevelNumber, + s: Setting + ) => { + if (lvl >= s.ExpertiseLevel) { + // this setting is shown anyway. + return false; + } + if (s.Key === this.highlightKey) { + return true; + } + // the user is searching for settings so make sure we even show advanced or developer settings + if (this.onSearch.getValue() !== '') { + return true; + } + if (s.Value === undefined) { + // no value set + return false; + } + return true; + }; + + mustShowCategory: ExpertiseLevelOverwrite = ( + lvl: ExpertiseLevelNumber, + cat: Category + ) => { + return cat.settings.some((setting) => this.mustShowSetting(lvl, setting)); + }; + + mustShowSubsystem: ExpertiseLevelOverwrite = ( + lvl: ExpertiseLevelNumber, + subsys: SubsystemWithExpertise + ) => { + return !!this.settings + .get(subsys.ConfigKeySpace) + ?.some((cat) => this.mustShowCategory(lvl, cat)); + }; + + @Output() + save = new EventEmitter(); + + private onSearch = new BehaviorSubject(''); + private onSettingsChange = new BehaviorSubject([]); + + @ViewChildren('navLink', { read: ElementRef }) + navLinks: QueryList | null = null; + + private subscription = Subscription.EMPTY; + + constructor( + public statusService: StatusService, + public configService: ConfigService, + private elementRef: ElementRef, + private changeDetectorRef: ChangeDetectorRef, + private scrollDispatcher: ScrollDispatcher, + private searchService: FuzzySearchService, + private actionIndicator: ActionIndicatorService, + private portapi: PortapiService, + private dialog: SfngDialogService + ) { } + + openImportDialog() { + const importConfig: ImportConfig = { + type: 'setting', + key: this.scope, + }; + this.dialog.create(ImportDialogComponent, { + data: importConfig, + autoclose: false, + backdrop: 'light', + }); + } + + toggleExportMode() { + this.exportMode = !this.exportMode; + + if (this.exportMode) { + this.actionIndicator.info( + 'Settings Export', + 'Please select all settings you want to export and press "Save" to generate the export. Note that settings with system defaults cannot be exported and are hidden.' + ); + } + } + + generateExport() { + let selectedKeys = Object.keys(this.selectedSettings).reduce((sum, key) => { + if (this.selectedSettings[key]) { + sum.push(key); + } + + return sum; + }, [] as string[]); + + if (selectedKeys.length === 0) { + selectedKeys = Array.from(this.settings.values()).reduce( + (sum, current) => { + current.forEach((cat) => { + cat.settings.forEach((s) => { + if (s.Value !== undefined) { + sum.push(s.Key); + } + }); + }); + + return sum; + }, + [] as string[] + ); + } + + this.portapi.exportSettings(selectedKeys, this.scope).subscribe({ + next: (exportBlob) => { + const exportConfig: ExportConfig = { + type: 'setting', + content: exportBlob, + }; + + this.dialog.create(ExportDialogComponent, { + data: exportConfig, + backdrop: 'light', + autoclose: true, + }); + + this.exportMode = false; + }, + error: (err) => { + const msg = this.actionIndicator.getErrorMessgae(err); + this.actionIndicator.error('Failed To Generate Export', msg); + }, + }); + } + + saveSetting(event: SaveSettingEvent, s: Setting) { + this.save.next(event); + const subsys = this.subsystems.find( + (subsys) => s.Key === subsys.ToggleOptionKey + ); + if (!!subsys) { + // trigger a reload of the page as we now might need to show more + // settings. + this.onSettingsChange.next(this.onSettingsChange.getValue()); + } + } + + trackSubsystem: TrackByFunction = + this.statusService.trackSubsystem; + + trackCategory(_: number, cat: Category) { + return cat.name; + } + + ngOnInit(): void { + this.subscription = combineLatest([ + this.onSettingsChange, + this.statusService.querySubsystem(), + this.onSearch.pipe(debounceTime(250)), + this.configService.watch('core/releaseLevel'), + ]) + .pipe(debounceTime(10)) + .subscribe( + ([settings, subsystems, searchTerm, currentReleaseLevelSetting]) => { + this.subsystems = subsystems.map((s) => ({ + ...s, + // we start with developer and decrease to the lowest number required + // while grouping the settings. + minimumExpertise: ExpertiseLevelNumber.developer, + isDisabled: false, + hasUserDefinedValues: false, + })); + this.others = []; + this.settings = new Map(); + + // Get the current release level as a number (fallback to 'stable' is something goes wrong) + const currentReleaseLevel = releaseLevelFromName( + currentReleaseLevelSetting || ('stable' as any) + ); + + // Make sure we only display settings that are allowed by the releaselevel setting. + settings = settings.filter( + (setting) => setting.ReleaseLevel <= currentReleaseLevel + ); + + // Use fuzzy-search to limit the number of settings shown. + const filtered = this.searchService.searchList(settings, searchTerm, { + ignoreLocation: true, + ignoreFieldNorm: true, + threshold: 0.1, + minMatchCharLength: 3, + keys: [ + { name: 'Name', weight: 3 }, + { name: 'Description', weight: 2 }, + ], + }); + + // The search service wraps the items in a search-result object. + // Unwrap them now. + settings = filtered.map((res) => res.item); + + // use order-annotations to sort the settings. This affects the order of + // the categories as well as the settings inside the categories. + settings.sort((a, b) => { + const orderA = a.Annotations?.['safing/portbase:ui:order'] || 0; + const orderB = b.Annotations?.['safing/portbase:ui:order'] || 0; + return orderA - orderB; + }); + + settings.forEach((setting) => { + let pushed = false; + this.subsystems.forEach((subsys) => { + if ( + setting.Key.startsWith( + subsys.ConfigKeySpace.slice('config:'.length) + ) + ) { + // get the category name annotation and fallback to 'others' + let catName = 'other'; + if ( + !!setting.Annotations && + !!setting.Annotations['safing/portbase:ui:category'] + ) { + catName = setting.Annotations['safing/portbase:ui:category']; + } + + // ensure we have a category array for the subsystem. + let categories = this.settings.get(subsys.ConfigKeySpace); + if (!categories) { + categories = []; + this.settings.set(subsys.ConfigKeySpace, categories); + } + + // find or create the appropriate category object. + let cat = categories.find((c) => c.name === catName); + if (!cat) { + cat = { + name: catName, + minimumExpertise: ExpertiseLevelNumber.developer, + settings: [], + collapsed: false, + hasUserDefinedValues: false, + }; + categories.push(cat); + } + + // add the setting to the category object and update + // the minimum expertise required for the category. + cat.settings.push(setting); + if (setting.ExpertiseLevel < cat.minimumExpertise) { + cat.minimumExpertise = setting.ExpertiseLevel; + } + + pushed = true; + } + }); + + // if we did not push the setting to some subsystem + // we need to push it to "others" + if (!pushed) { + this.others!.push(setting); + } + }); + + if (this.others.length === 0) { + this.others = null; + } + + // Reduce the subsystem array to only contain subsystems that + // actually have settings to show. + // Also update the minimumExpertiseLevel for those subsystems + this.subsystems = this.subsystems + .filter((subsys) => { + return !!this.settings.get(subsys.ConfigKeySpace); + }) + .map((subsys) => { + let categories = this.settings.get(subsys.ConfigKeySpace)!; + let hasUserDefinedValues = false; + categories.forEach((c) => { + c.hasUserDefinedValues = c.settings.some( + (s) => s.Value !== undefined + ); + hasUserDefinedValues = + c.hasUserDefinedValues || hasUserDefinedValues; + }); + + subsys.hasUserDefinedValues = hasUserDefinedValues; + + let toggleOption: Setting | undefined = undefined; + for (let c of categories) { + toggleOption = c.settings.find( + (s) => s.Key === subsys.ToggleOptionKey + ); + if (!!toggleOption) { + if ( + (toggleOption.Value !== undefined && !toggleOption.Value) || + (toggleOption.Value === undefined && + !toggleOption.DefaultValue) + ) { + subsys.isDisabled = true; + + // remove all settings for all subsystem categories + // except for the ToggleOption. + categories = categories + .map((c) => ({ + ...c, + settings: c.settings.filter( + (s) => s.Key === toggleOption!.Key + ), + })) + .filter((cat) => cat.settings.length > 0); + this.settings.set(subsys.ConfigKeySpace, categories); + } + break; + } + } + + // reduce the categories to find the smallest expertise level requirement. + subsys.minimumExpertise = categories.reduce((min, current) => { + if (current.minimumExpertise < min) { + return current.minimumExpertise; + } + return min; + }, ExpertiseLevelNumber.developer as ExpertiseLevelNumber); + + return subsys; + }); + + // Force the core subsystem to the end. + if (this.subsystems.length >= 2 && this.subsystems[0].ID === 'core') { + this.subsystems.push( + this.subsystems.shift() as SubsystemWithExpertise + ); + } + + // Notify the user interface that we're done loading + // the settings. + this.loading = false; + + // If there's a highlightKey set and we have not yet scrolled + // to it (because it was set during component bootstrap) we + // need to scroll there now. + if (this._highlightKey !== null && !this._scrolledToHighlighted) { + this._scrolledToHighlighted = true; + + // Use the next animation frame for scrolling + window.requestAnimationFrame(() => { + this.scrollTo(this._highlightKey || ''); + }); + } + } + ); + } + + ngAfterViewInit() { + this.subscription = new Subscription(); + + // Whenever our scroll-container is scrolled we might + // need to update which setting is currently highlighted + // in the settings-navigation. + this.subscription.add( + this.scrollDispatcher + .scrolled(10) + .subscribe(() => this.intersectionCallback()) + ); + + // Also, entries in the settings-navigation might become + // visible with expertise/release level changes so make + // sure to recalculate the current one whenever a change + // happens. + this.subscription.add( + this.navLinks?.changes.subscribe(() => { + this.intersectionCallback(); + this.changeDetectorRef.detectChanges(); + }) + ); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + this.onSearch.complete(); + } + + /** + * Calculates which navigation entry should be highlighted + * depending on the scroll position. + */ + private intersectionCallback() { + // search our parents for the element that's scrollable + let elem: HTMLElement = this.elementRef.nativeElement; + while (!!elem) { + if (elem.scrollTop > 0) { + break; + } + elem = elem.parentElement!; + } + + // if there's no scrolled/scrollable parent element + // our content itself is scrollable so use our own + // host element as the anchor for the calculation. + if (!elem) { + elem = this.elementRef.nativeElement; + } + + // get the elements offset to page-top + var offsetTop = 0; + if (!!elem) { + const viewRect = elem.getBoundingClientRect(); + offsetTop = viewRect.top; + } + + this.navLinks?.some((link) => { + const subsystem = link.nativeElement.getAttribute('subsystem'); + const category = link.nativeElement.getAttribute('category'); + + const lastChild = (link.nativeElement as HTMLElement) + .lastElementChild as HTMLElement; + if (!lastChild) { + return false; + } + + const rect = lastChild.getBoundingClientRect(); + const styleBox = getComputedStyle(lastChild); + + const offset = + rect.top + + rect.height - + parseInt(styleBox.marginBottom) - + parseInt(styleBox.paddingBottom); + + if (offset >= offsetTop) { + this.activeSection = subsystem; + this.activeCategory = category; + return true; + } + + return false; + }); + this.changeDetectorRef.detectChanges(); + } + + /** + * @private + * Performs a smooth-scroll to the given anchor element ID. + * + * @param id The ID of the anchor element to scroll to. + */ + scrollTo(id: string, cat?: Category) { + if (!!cat) { + cat.collapsed = false; + } + document.getElementById(id)?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest', + }); + } +} diff --git a/desktop/angular/src/app/shared/config/config.module.ts b/desktop/angular/src/app/shared/config/config.module.ts new file mode 100644 index 00000000..127032af --- /dev/null +++ b/desktop/angular/src/app/shared/config/config.module.ts @@ -0,0 +1,77 @@ +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { + SfngSelectModule, + SfngTipUpModule, + SfngToggleSwitchModule, + SfngTooltipModule, +} from '@safing/ui'; +import { MarkdownModule } from 'ngx-markdown'; +import { ExpertiseModule } from '../expertise/expertise.module'; +import { SfngFocusModule } from '../focus'; +import { SfngMenuModule } from '../menu'; +import { SfngMultiSwitchModule } from '../multi-switch'; +import { BasicSettingComponent } from './basic-setting/basic-setting'; +import { ConfigSettingsViewComponent } from './config-settings'; +import { FilterListComponent } from './filter-lists'; +import { GenericSettingComponent } from './generic-setting'; +import { + OrderedListComponent, + OrderedListItemComponent, +} from './ordererd-list'; +import { RuleListItemComponent } from './rule-list/list-item'; +import { RuleListComponent } from './rule-list/rule-list'; +import { SafePipe } from './safe.pipe'; +import { ExportDialogComponent } from './export-dialog/export-dialog.component'; +import { ImportDialogComponent } from './import-dialog/import-dialog.component'; +import { SfngAppIconModule } from '../app-icon'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + DragDropModule, + SfngTooltipModule, + SfngSelectModule, + SfngMultiSwitchModule, + SfngFocusModule, + SfngMenuModule, + SfngTipUpModule, + FontAwesomeModule, + MarkdownModule, + RouterModule, + ExpertiseModule, + SfngToggleSwitchModule, + MarkdownModule, + SfngAppIconModule + ], + declarations: [ + BasicSettingComponent, + FilterListComponent, + OrderedListComponent, + OrderedListItemComponent, + RuleListComponent, + RuleListItemComponent, + ConfigSettingsViewComponent, + GenericSettingComponent, + SafePipe, + ExportDialogComponent, + ImportDialogComponent, + ], + exports: [ + BasicSettingComponent, + FilterListComponent, + OrderedListComponent, + OrderedListItemComponent, + RuleListComponent, + RuleListItemComponent, + ConfigSettingsViewComponent, + GenericSettingComponent, + SafePipe, + ], +}) +export class ConfigModule { } diff --git a/desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.html b/desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.html new file mode 100644 index 00000000..da8a3cb1 --- /dev/null +++ b/desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.html @@ -0,0 +1,19 @@ +
+

+ {{ dialogRef.data.type === "setting" ? "Settings" : "Profile" }} Export +

+ + +
+ + + +
+ + +
\ No newline at end of file diff --git a/desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.ts b/desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.ts new file mode 100644 index 00000000..f451732e --- /dev/null +++ b/desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.ts @@ -0,0 +1,67 @@ +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnInit, + inject, +} from '@angular/core'; +import { SFNG_DIALOG_REF, SfngDialogRef } from '@safing/ui'; +import { ActionIndicatorService } from '../../action-indicator'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; + +export interface ExportConfig { + content: string; + type: 'setting' | 'profile'; +} + +@Component({ + templateUrl: './export-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + @apply flex flex-col gap-2 overflow-hidden; + min-height: 24rem; + min-width: 24rem; + max-height: 40rem; + max-width: 40rem; + } + `, + ], +}) +export class ExportDialogComponent implements OnInit { + readonly dialogRef: SfngDialogRef< + ExportDialogComponent, + unknown, + ExportConfig + > = inject(SFNG_DIALOG_REF); + + private readonly elementRef: ElementRef = inject(ElementRef); + private readonly document = inject(DOCUMENT); + private readonly uai = inject(ActionIndicatorService); + private readonly integration = inject(INTEGRATION_SERVICE); + + content = ''; + + ngOnInit(): void { + this.content = '```yaml\n' + this.dialogRef.data.content + '\n```'; + } + + download() { + const blob = new Blob([this.dialogRef.data.content], { type: 'text/yaml' }); + + const elem = this.document.createElement('a'); + elem.href = window.URL.createObjectURL(blob); + elem.download = 'export.yaml'; + this.elementRef.nativeElement.appendChild(elem); + elem.click(); + this.elementRef.nativeElement.removeChild(elem); + } + + copyToClipboard() { + this.integration.writeToClipboard(this.dialogRef.data.content) + .then(() => this.uai.success('Copied to Clipboard')) + .catch(() => this.uai.error('Failed to Copy to Clipboard')); + } +} diff --git a/desktop/angular/src/app/shared/config/filter-lists/filter-list.html b/desktop/angular/src/app/shared/config/filter-lists/filter-list.html new file mode 100644 index 00000000..a6a72a87 --- /dev/null +++ b/desktop/angular/src/app/shared/config/filter-lists/filter-list.html @@ -0,0 +1,55 @@ +
+ + +
+
+ + + + + + + {{ !!node.license ? 'License: ' + node.license : '' }} + + + + + + +
+ +
+
+ + + Expand + + + + Collapse + +
+
+ +
+
+ + + +
+
+
+ + + + + +
diff --git a/desktop/angular/src/app/shared/config/filter-lists/filter-list.scss b/desktop/angular/src/app/shared/config/filter-lists/filter-list.scss new file mode 100644 index 00000000..1b41d179 --- /dev/null +++ b/desktop/angular/src/app/shared/config/filter-lists/filter-list.scss @@ -0,0 +1,101 @@ +:host { + display: block; + overflow: hidden; + + @apply bg-cards-secondary; + @apply rounded; + @apply p-2; + @apply h-full; +} + +.node { + position: relative; + display: flex; + flex-direction: column; + + justify-content: flex-start; + @apply py-1; + + .head { + display: flex; + flex-direction: row; + align-items: baseline; + + input { + @apply mr-2; + position: relative; + top: 2px; + } + + label { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + span.details { + opacity: 0; + text-transform: capitalize; + font-size: 0.9em; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 6rem; + @apply text-tertiary; + } + + &:hover { + span.details { + opacity: 1; + + } + } + } + + span.name { + @apply text-primary; + + .id { + @apply text-tertiary; + font-style: italic; + } + } + + .description { + position: relative; + top: -2px; + + @apply text-tertiary; + } + + div.expand { + cursor: pointer; + @apply text-secondary; + display: flex; + flex-direction: row; + align-items: center; + @apply pb-2; + + fa-icon { + margin-right: 0.25rem; + } + } + + .children { + display: flex; + flex-direction: column; + margin-left: 1.25rem; + } + + .border { + position: absolute; + top: 1.2rem; + bottom: 0.5rem; + width: 0.7rem; + margin-left: -0.85rem; + border: 1px solid; + border-right: none; + border-top: none; + @apply border-cards-tertiary; + } +} diff --git a/desktop/angular/src/app/shared/config/filter-lists/filter-list.ts b/desktop/angular/src/app/shared/config/filter-lists/filter-list.ts new file mode 100644 index 00000000..b39c45b4 --- /dev/null +++ b/desktop/angular/src/app/shared/config/filter-lists/filter-list.ts @@ -0,0 +1,293 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { PortapiService, Record } from '@safing/portmaster-api'; +import { Subscription } from 'rxjs'; +import { moveInOutListAnimation } from '../../animations'; + +interface Category { + name: string; + id: string; + description: string; + parent?: string | null; +} + +interface Source { + name: string; + id: string; + description: string; + category: string; + // urls: Resource[]; // we don't care about the actual URLs here. + website: string; + contribute: string; + license: string; +} + +interface FilterListIndex extends Record { + version: string; + schemaVersion: string; + categories: Category[]; + sources: Source[]; +} + +interface TreeNode { + id: string; + name: string; + description: string; + children: TreeNode[]; + expanded: boolean; + selected: boolean; + parent?: TreeNode; + website?: string; + license?: string; + hasSelectedChildren: boolean; +} + +@Component({ + selector: 'app-filter-list', + templateUrl: './filter-list.html', + styleUrls: ['./filter-list.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterListComponent), + multi: true, + } + ], + animations: [ + moveInOutListAnimation, + ] +}) +export class FilterListComponent implements OnInit, OnDestroy, ControlValueAccessor { + /** The actual filter-list index as loaded from the portmaster. */ + private index: FilterListIndex | null = null; + + /** @private a list of "tree-nodes" to render */ + nodes: TreeNode[] = []; + + /** A lookup map for fast ID to TreeNode lookups */ + private lookupMap: Map = new Map(); + + /** @private forward blur events to the onTouch callback. */ + @HostListener('blur') + onBlur() { + this.onTouch(); + } + + /** The currently selected IDs. */ + private selectedIDs: string[] = []; + + /** Subscription to watch the filterlist index. */ + private watchSubscription = Subscription.EMPTY; + + constructor(private portapi: PortapiService, + private changeDetectorRef: ChangeDetectorRef) { } + + ngOnInit() { + this.watchSubscription = + this.portapi.watch("cache:intel/filterlists/index") + .subscribe( + index => this.updateIndex(index), + err => { + // Filter list index not yet loaded. + console.error(`failed to get fitlerlist index`, err); + } + ); + } + + ngOnDestroy() { + this.watchSubscription.unsubscribe(); + } + + /** The onChange callback registered by ngModel or form controls */ + private _onChange: (v: string[]) => void = () => { }; + + /** Registers the onChange callback required by ControlValueAccessor */ + registerOnChange(fn: (v: string[]) => void) { + this._onChange = fn; + } + + /** The _onTouch callback registered by ngModel and form controls */ + private onTouch: () => void = () => { }; + + /** Registeres the onTouch callback required by ControlValueAccessor. */ + registerOnTouched(fn: () => void) { + this.onTouch = fn; + } + + /** + * Update the currently selected IDs. Used by ngModel + * and form controls. Implements ControlValueAccessor. + * + * @param ids A list of selected IDs + */ + writeValue(ids: string[]) { + this.selectedIDs = ids; + if (!!this.index) { + this.updateIndex(this.index); + } + } + + /** + * + * @param index The filter list index. + */ + private updateIndex(index: FilterListIndex) { + this.index = index; + + var nodes: TreeNode[] = []; + let lm = new Map(); + let childCategories: Category[] = []; + + // Create a tree-node for each category + this.index.categories.forEach(category => { + let tn: TreeNode = { + id: category.id, + description: category.description, + name: category.name, + children: [], + expanded: this.lookupMap.get(category.id)?.expanded || false, // keep it expanded if the user did not change anything. + selected: false, + hasSelectedChildren: false, + }; + + lm.set(category.id, tn) + + // if the category does not have a parent + // it's a root node. + if (!category.parent) { + nodes.push(tn); + } else { + // we need to handle child-categories later. + childCategories.push(category); + } + }); + + // iterate over all "child" categories and add + // them to the correct parent (which must be in lm already.) + childCategories.forEach(category => { + const tn = lm.get(category.id)!; + const parent = lm.get(category.parent!); + // if the parent category does not exist ignore it + if (!parent) { + return; + } + + parent.children.push(tn); + tn.parent = parent; + }); + + this.index.sources.forEach(source => { + let category = lm.get(source.category); + if (!category) { + return; + } + + let tn: TreeNode = { + id: source.id, + name: source.name, + description: source.description, + children: [], + expanded: false, + selected: false, + parent: category, + website: source.website, + license: source.license, + hasSelectedChildren: false + } + + // Add the source to the lookup-map + lm.set(source.id, tn); + + category.children.push(tn); + }); + + // make sure we expand all parent categories for + // all selected IDs so they are actually visible. + this.selectedIDs.forEach(id => { + const tn = lm.get(id); + if (!tn) { + return; + } + + this.updateNode(tn, true, true, true, false); + + let parent = tn.parent; + while (!!parent) { + parent.expanded = true; + parent.hasSelectedChildren = true; + parent = parent.parent; + } + }); + + this.nodes = nodes; + this.lookupMap = lm; + + this.changeDetectorRef.markForCheck(); + } + + /** Returns all actually selected IDs. */ + private getIDs() { + let ids: string[] = []; + + let collectIds = (n: TreeNode) => { + if (n.selected) { + // If the parent is selected we can ignore the + // childs because they must be selected as well. + ids.push(n.id); + return; + } + + n.children.forEach(child => collectIds(child)); + } + + this.nodes.forEach(node => collectIds(node)) + + return ids; + } + + updateNode(node: TreeNode, selected: boolean, updateChildren = true, updateParents = true, emit = true) { + if (node.selected === selected) { + // Nothing changed + return; + } + + // update the node an all children + node.selected = selected; + if (updateChildren) { + node.children.forEach(child => this.updateNode(child, selected, true, false, false)); + } + + // if we have a parent we might need to update + // the parent as well. + if (!!node.parent && updateParents) { + if (selected) { + // if we are now selected we might need to "select" the + // parent if all children are selected now. + const hasUnselected = node.parent.children.some(sibling => !sibling.selected); + if (!hasUnselected) { + // We need to update all parents but updating children + // is useless. + this.updateNode(node.parent, true, false, true, false); + } + } else if (node.parent.selected) { + // if we are unselected now we might need to "unselect" the parent + // but select siblings directly + const selectedSiblings = node.parent.children.filter(sibling => sibling.selected && sibling !== node); + this.updateNode(node.parent, false, false, true, false) + } + } + + if (emit) { + const ids = this.getIDs(); + this.selectedIDs = ids; + this._onChange(this.selectedIDs); + } + } + + /** @private TrackByFunction for tree nodes. */ + trackNode(_: number, node: TreeNode) { + return node.id; + } +} + diff --git a/desktop/angular/src/app/shared/config/filter-lists/index.ts b/desktop/angular/src/app/shared/config/filter-lists/index.ts new file mode 100644 index 00000000..07932b7e --- /dev/null +++ b/desktop/angular/src/app/shared/config/filter-lists/index.ts @@ -0,0 +1 @@ +export { FilterListComponent } from './filter-list'; diff --git a/desktop/angular/src/app/shared/config/generic-setting/generic-setting.html b/desktop/angular/src/app/shared/config/generic-setting/generic-setting.html new file mode 100644 index 00000000..7c2f9bd1 --- /dev/null +++ b/desktop/angular/src/app/shared/config/generic-setting/generic-setting.html @@ -0,0 +1,204 @@ +
+ + + +
+
+

+ + + + + + + + Saved {{ _setting?.RequiresRestart ? ' - Restart required' : (uiReloadRequired ? ' - Reload required' : '') }} + + + + + + + Invalid Value: {{ rejected }} + + + + + + + This feature requires a subscription. + +
+ + + + {{setting?.Key}} + + Beta + + Experimental + + Advanced + + Developer + + +
+ + + +
+ + + + + Quick Settings + + + + + {{quick.Name}} + + +
+ + + + + + + + +
+

This setting stacks on top of the following global setting:

+ + +
+
+ + + + + + + + + + + +
+

This setting stacks on top of the following global setting:

+ +
+
+ + + + + + + + +
+

This setting stacks on top of the following global setting:

+ + +
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + Inherited from Global Settings + + + App specific configuration + + +
+ +
+ + {{resetLabelText}} + +
+ + +
+ +

{{ _setting?.Name }}

+ + +
+
\ No newline at end of file diff --git a/desktop/angular/src/app/shared/config/generic-setting/generic-setting.scss b/desktop/angular/src/app/shared/config/generic-setting/generic-setting.scss new file mode 100644 index 00000000..14e2c9e5 --- /dev/null +++ b/desktop/angular/src/app/shared/config/generic-setting/generic-setting.scss @@ -0,0 +1,97 @@ +:host { + @apply block; + + &.ng-invalid { + @apply border border-red border-opacity-50; + } + + &.rejected { + .release-level.rejected { + opacity: 1; + } + } + + &.highlighted:not(.touched) { + .name { + animation: fade-color 5s ease-out; + } + } +} + +.stacked-values { + margin-top: 0.5rem; + opacity: 0.7; + @apply w-full; +} + +.unlock-button { + @apply flex w-6 h-6 rounded-full; + + justify-content: center; + align-items: center; + cursor: pointer; + + position: absolute; + right: calc(-1.5rem/2); + top: calc(50% - 1.5rem/2); + + &:hover { + @apply bg-blue; + } +} + +.description, +.help-text { + display: block; + @apply text-secondary; +} + +.help-text { + @apply mb-2; +} + +.notice { + display: block; + padding-left: 0.5rem; + padding-right: 0.5rem; + @apply mb-4; + @apply text-secondary; + + fa-icon { + @apply mr-2; + } +} + +.help-text { + @apply p-4; + @apply bg-cards-secondary; + @apply rounded; + + .toggle { + position: relative; + left: -0.25rem; + cursor: pointer; + + fa-icon { + @apply pr-1; + } + + &:hover { + @apply text-primary; + } + } +} + +@keyframes fade-color { + 0% { + @apply text-blue; + } + + 90% { + @apply text-blue; + } + + 100% { + @apply text-primary; + } +} diff --git a/desktop/angular/src/app/shared/config/generic-setting/generic-setting.ts b/desktop/angular/src/app/shared/config/generic-setting/generic-setting.ts new file mode 100644 index 00000000..4ff5a7f3 --- /dev/null +++ b/desktop/angular/src/app/shared/config/generic-setting/generic-setting.ts @@ -0,0 +1,715 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, HostBinding, Input, OnInit, Output, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NgModel } from '@angular/forms'; +import { BaseSetting, ConfigService, ExpertiseLevel, ExpertiseLevelNumber, ExternalOptionHint, OptionType, PortapiService, QuickSetting, ReleaseLevel, SPNService, SettingValueType, UserProfile, WellKnown, applyQuickSetting } from '@safing/portmaster-api'; +import { SfngDialogRef, SfngDialogService } from '@safing/ui'; +import { Button } from 'js-yaml-loader!../../../i18n/helptexts.yaml'; +import { Subject } from 'rxjs'; +import { debounceTime, tap } from 'rxjs/operators'; +import { ActionIndicatorService } from '../../action-indicator'; +import { fadeInAnimation, fadeOutAnimation } from '../../animations'; +import { ExpertiseService } from '../../expertise/expertise.service'; +import { SPNAccountDetailsComponent } from '../../spn-account-details'; + +export interface SaveSettingEvent = any> { + key: string; + value: SettingValueType; + isDefault: boolean; + rejected?: (err: any) => void + accepted?: () => void +} + +@Component({ + selector: 'app-generic-setting', + templateUrl: './generic-setting.html', + exportAs: 'appGenericSetting', + styleUrls: ['./generic-setting.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeOutAnimation + ] +}) +export class GenericSettingComponent> implements OnInit { + // + // Constants used in the template. + // + + readonly optionHint = ExternalOptionHint; + readonly expertiseNames = ExpertiseLevel + readonly expertise = ExpertiseLevelNumber; + readonly optionType = OptionType; + readonly releaseLevel = ReleaseLevel; + readonly wellKnown = WellKnown; + + @ViewChild('helpTemplate', { read: TemplateRef, static: true }) + helpTemplate: TemplateRef | null = null; + private helpDialogRef: SfngDialogRef | null = null; + + // Whether or not the user needs to upgrade his/her account before + // this setting is valid. + _upgradeRequired = false; + + /** + * Whether or not the component/setting is disabled and should + * be read-only. + */ + @Input() + @HostBinding('class.disabled') + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v); + } + get disabled() { + return this._disabled || this._upgradeRequired; + } + private _disabled: boolean = false; + + /** Returns the symbolMap annoation for endpoint-lists */ + get symbolMap() { + return this.setting?.Annotations[WellKnown.EndpointListVerdictNames] || { + '+': 'Allow', + '-': 'Block' + }; + } + + /** Whether or not the setting should be in select mode */ + @Input() + set selectMode(v: any) { + this._selectMode = coerceBooleanProperty(v) + + if (!this.selectMode) { + this.selected = false; + this.selectedChange.next(false); + } + } + get selectMode() { return this._selectMode } + private _selectMode = false; + + /** Whether or not the setting has been selected */ + @Input() + set selected(v: any) { + this._selected = coerceBooleanProperty(v) + } + get selected() { return this._selected } + private _selected = false; + + /** Emits when the user (de-) selectes the setting. Can be used for two-way binding */ + @Output() + selectedChange = new EventEmitter(); + + /** Controls whether or not header with the setting name and success/failure markers is shown */ + @Input() + set showHeader(v: any) { + this._showHeader = coerceBooleanProperty(v); + } + get showHeader() { return this._showHeader } + private _showHeader = true; + + /** Controls whether or not the blue or red status borders are shown */ + @Input() + set enableActiveBorder(v: any) { + this._enableActiveBorder = coerceBooleanProperty(v); + } + get enableActiveBorder() { return this._enableActiveBorder } + private _enableActiveBorder = true; + + /** + * Whether or not the component should be displayed as "locked" + * when the default value is used (that is, no 'Value' property + * in the setting) + */ + @Input() + set lockDefaults(v: any) { + this._lockDefaults = coerceBooleanProperty(v); + } + get lockDefaults() { + return this._lockDefaults; + } + private _lockDefaults: boolean = false; + + /** The label to display in the reset-value button */ + @Input() + resetLabelText = 'Reset'; + + /** Emits an event whenever the setting should be saved. */ + @Output() + save = new EventEmitter>(); + + /** Wether or not stackable values should be displayed. */ + @Input() + set displayStackable(v: any) { + this._displayStackable = coerceBooleanProperty(v); + } + get displayStackable() { + return this._displayStackable; + } + private _displayStackable = false; + + /** + * Whether or not the help text is currently shown + */ + @Input() + set showHelp(v: any) { + this._showHelp = coerceBooleanProperty(v); + } + get showHelp() { + return this._showHelp; + } + private _showHelp = false; + + /** Used internally to publish save events. */ + private triggerSave = new Subject(); + + /** Whether or not the value was reset. */ + wasReset = false; + + /** Whether or not a save request was rejected */ + @HostBinding('class.rejected') + get rejected() { + return this._rejected; + } + private _rejected = null; + + @HostBinding('class.saved') + get changeAccepted() { + return this._changeAccepted; + } + private _changeAccepted = false; + + /** + * @private + * Returns the external option type hint from a setting. + * + * @param opt The setting for with to return the external option hint + */ + externalOptType(opt: S | null): ExternalOptionHint | null { + return opt?.Annotations?.[WellKnown.DisplayHint] || null; + } + + /** + * @private + * Returns whether or not a restart is pending for this setting + * to apply. + */ + get restartPending(): boolean { + return !!this._setting?.Annotations?.[WellKnown.RestartPending]; + } + + /** + * @private + * Returns whether or not a UI reload is required for this setting + * to apply + */ + get uiReloadRequired(): boolean { + return this._setting?.Annotations?.[WellKnown.RequiresUIReload] !== undefined; + } + + /** + * Returns true if the setting has been touched (modified) by the user + * since the component has been rendered. + */ + @HostBinding('class.touched') + get touched() { + return this._touched; + } + private _touched = false; + + /** + * Returns true if the settings is currently locked. + */ + @HostBinding('class.locked') + get isLocked() { + return (this.wasReset || !this.userConfigured) && this.lockDefaults; + } + + /** + * Returns true if the user has configured the setting on their + * own or if the default value is being used. + */ + @HostBinding('class.changed') + get userConfigured() { + return this.setting?.Value !== undefined; + } + + /** + * Returns true if the setting is dirty. That is, the user + * has changed the setting in the view but it has not yet + * been saved. + */ + @HostBinding('class.dirty') + get dirty() { + if (typeof this._currentValue !== 'object') { + return this._currentValue !== this._savedValue; + } + // JSON object (OptionType.StringArray) require will + // not be the same reference so we need to compare their + // string representations. That's a bit more costly but should + // still be fast enough. + // TODO(ppacher): calculate this only when required. + return JSON.stringify(this._currentValue) !== JSON.stringify(this._savedValue) + } + + /** + * Returns true if the setting is pristine. That is, the + * settings default value is used and the user has not yet + * changed the value inside the view. + */ + @HostBinding('class.pristine') + get pristine() { + return !this.dirty && !this.userConfigured + } + + /** A list of buttons for the tip-up */ + sfngTipUpButtons: Button[] = []; + + /** + * Unlock the setting if it is locked. Unlocking will + * emit the default value to be safed for the setting. + */ + unlock() { + if (!this.isLocked || !this.setting) { + return; + } + + this._touched = true; + this.wasReset = false; + let value = this.defaultValue; + + if (this.stackable) { + // TODO(ppacher): fix this one once string[] options can be + // stackable + value = [] as SettingValueType; + } + + this.updateValue(value, true); + // update the settings value now so the UI + // responds immediately. + this.setting!.Value = value; + } + + /** True if the current setting is stackable */ + get stackable() { + return !!this.setting?.Annotations[WellKnown.Stackable]; + } + + /** Wether or not stackable values should be shown right now */ + get showStackable() { + return this.stackable && this.displayStackable; + } + + /** + * @private + * Toggle Whether or not the help text is displayed + */ + toggleHelp() { + this.showHelp = !this.showHelp; + } + + /** + * @private + * Toggle Whether or not the setting is currently locked. + */ + toggleLock() { + if (this.isLocked) { + this.unlock(); + return; + } + + this.resetValue(); + } + + /** + * @private + * Closes the help dialog. + */ + closeHelpDialog() { + this.helpDialogRef?.close(); + } + + @ViewChild(NgModel, { static: false }) + model: NgModel | null = null; + + /** + * The actual setting that should be managed. + * The setter also updates the "currently" used + * value (which is either user configured or + * the default). See {@property userConfigured}. + */ + @Input() + set setting(s: S | null) { + this.sfngTipUpButtons = []; + + this._setting = s; + if (!s) { + this._currentValue = null; + return; + } + + if (this._setting?.Help) { + this.sfngTipUpButtons = [ + { + name: 'Show More', + action: { + ID: '', + Text: '', + Type: 'ui', + Run: async () => { + if (!this.helpTemplate) { + return; + } + + // close any existing help dialog for THIS setting. + if (!!this.helpDialogRef) { + this.helpDialogRef.close(); + } + + // Create a new dialog form the helpTemplate + const portal = new TemplatePortal(this.helpTemplate, this.viewRef); + const ref = this.dialog.create(portal, { + // we don't use a backdrop and make the dialog dragable so the user can + // move it somewhere else and keep it open while configuring the setting. + backdrop: false, + dragable: true, + }); + + // make sure we reset the helpDialogRef to null once it get's clsoed. + this.helpDialogRef = ref; + this.helpDialogRef.onClose.subscribe(() => { + // but only if helpDialogRef still points to the same + // dialog reference. Otherwise we got closed because the user + // opened a new one and helpDialogRef already points to the new + // dialog. + if (this.helpDialogRef === ref) { + this.helpDialogRef = null; + } + }); + }, + Payload: undefined, + }, + }, + ] + } + this.updateActualValue(); + } + get setting(): S | null { + return this._setting; + } + + /** + * The defaultValue input allows to overwrite the default + * value of the setting. + */ + @Input() + set defaultValue(val: SettingValueType) { + this._defaultValue = val; + this.updateActualValue(); + } + + get defaultValue() { + // Return cached value. + if (this._defaultValue !== null) { + return this._defaultValue; + } + + // Stackable options are displayed differently. + if (this.stackable) { + if (this.setting?.GlobalDefault === undefined && this.setting?.DefaultValue !== null) { + return this.setting?.DefaultValue; + } + return [] as SettingValueType; + } + + // Return global, then default value. + if (this.setting?.GlobalDefault !== undefined) { + return this.setting.GlobalDefault + } + return this.setting?.DefaultValue + } + + /* An optional default value overwrite */ + _defaultValue: SettingValueType | null = null; + + /* Whether or not the setting has been saved */ + saved = true; + + /* The settings value, updated by the setting() setter */ + _setting: S | null = null; + + /* The currently configured value. Updated by the setting() setter */ + _currentValue: SettingValueType | null = null; + + /* The currently saved value. Updated by the setting() setter */ + _savedValue: SettingValueType | null = null; + + /* Used to cache the value of a basic-setting because we only want to save that on blur */ + _basicSettingsValueCache: SettingValueType | null = null + + /** Whether or not the network rating system is enabled. */ + networkRatingEnabled$ = this.configService.networkRatingEnabled$; + + get expertiseLevel() { + return this.expertiseService.change; + } + + constructor( + private expertiseService: ExpertiseService, + private configService: ConfigService, + private portapi: PortapiService, + private dialog: SfngDialogService, + private changeDetectorRef: ChangeDetectorRef, + private actionIndicator: ActionIndicatorService, + private spn: SPNService, + private viewRef: ViewContainerRef, + private destryoRef: DestroyRef, + ) { } + + ngOnInit() { + this.triggerSave + .pipe( + debounceTime(500), + takeUntilDestroyed(this.destryoRef), + ) + .subscribe(() => this.emitSaveRequest()) + + // watch the SPN user profile so we know which feature_ids + // are available for the user. + this.spn.profile$ + .pipe(takeUntilDestroyed(this.destryoRef)) + .subscribe((profile: UserProfile | null) => { + let value = this.setting?.Annotations[WellKnown.RequiresFeatureID] + if (value === undefined) { + this._upgradeRequired = false; + } else { + if (!Array.isArray(value)) { + value = [value]; + } + + this._upgradeRequired = value.some(val => !(profile?.current_plan?.feature_ids || []).includes(val)) + } + + this.changeDetectorRef.markForCheck(); + }) + } + + /** + * @private + * Resets the value of setting by discarding any user + * configured values and reverting back to the default + * value. + */ + resetValue() { + if (!this._setting) { + return; + } + this._touched = true; + + this._currentValue = this.defaultValue; + this.wasReset = true; + + this.triggerSave.next(); + } + + /** + * @private + * Aborts/reverts the current change to the value that's + * already saved. + */ + abortChange() { + this._currentValue = this._savedValue; + this._touched = true; + this._rejected = null; + } + + /** + * @private + * Update the current value by applying a quick-setting. + * + * @param qs The quick-settting to apply + */ + applyQuickSetting(qs: QuickSetting>) { + if (this.disabled) { + return; + } + + const value = applyQuickSetting(this._currentValue, qs); + if (value === null) { + return; + } + + this.updateValue(value, true); + } + + openAccountDetails() { + this.dialog.create(SPNAccountDetailsComponent, { + autoclose: true, + backdrop: 'light' + }) + } + + restartNow() { + if (this._setting?.RequiresRestart) { + this.dialog.confirm({ + header: 'Restart Portmaster', + message: 'Do you want to restart the Portmaster now?', + buttons: [ + { + id: 'no', + text: 'Maybe Later', + class: 'outline', + }, + { + id: 'restart', + text: 'Restart', + class: 'danger' + } + ] + }) + .onAction('restart', () => + this.portapi.restartPortmaster() + .subscribe(this.actionIndicator.httpObserver( + 'Restarting ...', + 'Failed to Restart', + )) + ) + .onAction('no', () => { + this._changeAccepted = false; + this.changeDetectorRef.markForCheck(); + }); + + return; + } + + if (this.uiReloadRequired) { + this.portapi.reloadUI() + .pipe( + tap(() => { + setTimeout(() => window.location.reload(), 1000) + }) + ) + .subscribe(this.actionIndicator.httpObserver( + 'Reloading UI ...', + 'Failed to Reload UI', + )) + } + } + + /** + * Emits a save request to the parent component. + */ + private _saveInterval: any; + private emitSaveRequest() { + const isDefault = this.wasReset; + let value = this._setting!['Value']; + + if (isDefault) { + delete (this._setting!['Value']); + } else { + this._setting!.Value = this._currentValue; + } + + + let wasReset = this.wasReset; + this.wasReset = false; + this._rejected = null; + this._changeAccepted = false; + if (!!this._saveInterval) { + clearTimeout(this._saveInterval); + } + + this.save.next({ + key: this.setting!.Key, + isDefault: isDefault, + value: this._setting!.Value, + rejected: (err: any) => { + this._setting!['Value'] = value; + this._rejected = err; + this.changeDetectorRef.markForCheck(); + }, + accepted: () => { + if (!wasReset) { + this._changeAccepted = true; + // if no restart is required fade the "✔️ Saved" out after + // a few seconds. + if (!this._setting?.RequiresRestart) { + this._saveInterval = setTimeout(() => { + this._changeAccepted = false; + this._saveInterval = null; + this.changeDetectorRef.markForCheck(); + }, 4000); + } + } + + this.changeDetectorRef.markForCheck(); + + } + }) + } + + /** + * @private + * Used in our view as a ngModelChange callback to + * update the value. + * + * @param value The new value as emitted by the view + */ + updateValue(value: SettingValueType, save = false) { + this._touched = true; + + this._changeAccepted = false; + this._rejected = null; + if (!!this._saveInterval) { + clearTimeout(this._saveInterval); + } + + if (save) { + + this._currentValue = value; + this.triggerSave.next(); + } else { + this._basicSettingsValueCache = value; + } + } + + /** + * @private + * A list of quick-settings available for the setting. + * The getter makes sure to always return an array. + */ + get quickSettings(): QuickSetting>[] { + if (!this.setting || !this.setting.Annotations[WellKnown.QuickSetting]) { + return []; + } + + const quickSettings = this.setting.Annotations[WellKnown.QuickSetting]!; + + return Array.isArray(quickSettings) + ? quickSettings + : [quickSettings]; + } + + /** + * Determine the current, actual value of the setting + * by taking the settings Value, default Value or global + * default into account. + */ + private updateActualValue() { + if (!this.setting) { + return + } + + this.wasReset = false; + + const s = this.setting; + + const value = s.Value === undefined + ? this.defaultValue + : s.Value; + + + this._currentValue = value; + this._savedValue = value; + this._basicSettingsValueCache = value; + } +} diff --git a/desktop/angular/src/app/shared/config/generic-setting/index.ts b/desktop/angular/src/app/shared/config/generic-setting/index.ts new file mode 100644 index 00000000..0fbe8492 --- /dev/null +++ b/desktop/angular/src/app/shared/config/generic-setting/index.ts @@ -0,0 +1 @@ +export * from './generic-setting'; diff --git a/desktop/angular/src/app/shared/config/import-dialog/cursor.ts b/desktop/angular/src/app/shared/config/import-dialog/cursor.ts new file mode 100644 index 00000000..1ab638ee --- /dev/null +++ b/desktop/angular/src/app/shared/config/import-dialog/cursor.ts @@ -0,0 +1,90 @@ +// Credit to Liam (Stack Overflow) +// https://stackoverflow.com/a/41034697/3480193 +export class Cursor { + static getCurrentCursorPosition(parentElement: Node) { + var selection = window.getSelection(), + charCount = -1, + node; + + if (selection?.focusNode) { + if (Cursor._isChildOf(selection.focusNode, parentElement)) { + node = selection.focusNode; + charCount = selection.focusOffset; + + while (node) { + if (node === parentElement) { + break; + } + + if (node.previousSibling) { + node = node.previousSibling; + charCount += node.textContent?.length || 0 + } else { + node = node.parentNode; + if (node === null) { + break; + } + } + } + } + } + + return charCount; + } + + static setCurrentCursorPosition(chars: number, element: Node) { + if (chars >= 0) { + var selection = window.getSelection(); + + let range = Cursor._createRange(element, { count: chars }); + + if (range) { + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + } + + static _createRange(node: Node, chars: { count: number }, range?: Range): Range { + if (!range) { + range = document.createRange() + range.selectNode(node); + range.setStart(node, 0); + } + + if (chars.count === 0) { + range.setEnd(node, chars.count); + } else if (node && chars.count > 0) { + if (node.nodeType === Node.TEXT_NODE) { + if (node.textContent!.length < chars.count) { + chars.count -= node.textContent!.length; + } else { + range.setEnd(node, chars.count); + chars.count = 0; + } + } else { + for (var lp = 0; lp < node.childNodes.length; lp++) { + range = Cursor._createRange(node.childNodes[lp], chars, range); + + if (chars.count === 0) { + break; + } + } + } + } + + return range; + } + + static _isChildOf(node: Node, parentElement: Node) { + while (node !== null) { + if (node === parentElement) { + return true; + } + node = node.parentNode!; + } + + return false; + } +} diff --git a/desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.html b/desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.html new file mode 100644 index 00000000..c55709d4 --- /dev/null +++ b/desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.html @@ -0,0 +1,99 @@ +
+

+ Import {{ dialogRef.data.type === "setting" ? "Settings" : "Profile" }} +

+ + +
+ +Please paste the "Export Content" or use "Choose File" to select one from + your hard disk. + +

+
+
+ Configuration + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+
+
+ +
+ Warning + +
+ + + + {{ errorMessage }} +
+ +
    +
  • + This export contains unknown settings. To import it, you must enable + "Allow unknown settings". +
  • + +
  • + {{ + dialogRef.data.type === "setting" + ? "This export will overwrite settings that have been changed by you." + : "This export will overwrite an existing profile." + }} + + + And deletes {{ count }} previously merged profile{{ count > 1 ? 's' : '' }} + +
  • + +
  • + This export will require a restart of the Portmaster to take effect. +
  • +
+
+ +
+ + + +
+ + \ No newline at end of file diff --git a/desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.ts b/desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.ts new file mode 100644 index 00000000..57a5713c --- /dev/null +++ b/desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.ts @@ -0,0 +1,201 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + ViewChild, + inject, +} from '@angular/core'; +import { ImportResult, PortapiService, ProfileImportResult } from '@safing/portmaster-api'; +import { SFNG_DIALOG_REF, SfngDialogRef } from '@safing/ui'; +import { ActionIndicatorService } from '../../action-indicator'; +import { getSelectionOffset, setSelectionOffset } from './selection'; +import { Observable } from 'rxjs'; + +export interface ImportConfig { + key: string; + type: 'setting' | 'profile'; +} + +@Component({ + templateUrl: './import-dialog.component.html', + styles: [ + ` + :host { + @apply flex flex-col gap-2 overflow-hidden; + min-height: 24rem; + min-width: 24rem; + max-height: 40rem; + max-width: 40rem; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ImportDialogComponent { + readonly dialogRef: SfngDialogRef< + ImportDialogComponent, + unknown, + ImportConfig + > = inject(SFNG_DIALOG_REF); + + private readonly portapi = inject(PortapiService); + private readonly uai = inject(ActionIndicatorService); + private readonly cdr = inject(ChangeDetectorRef); + + @ViewChild('codeBlock', { static: true, read: ElementRef }) + codeBlockElement!: ElementRef; + + result: ImportResult | ProfileImportResult | null = null; + reset = false; + allowUnknown = false; + triggerRestart = false; + allowReplace = false; + + get replacedProfiles() { + if (this.result === null) { + return [] + } + + if ('replacesProfiles' in this.result) { + return this.result.replacesProfiles || []; + } + + return []; + } + + errorMessage: string = ''; + + get scope() { + return this.dialogRef.data; + } + + onBlur() { + const text = this.codeBlockElement.nativeElement.innerText; + this.updateAndValidate(text); + } + + onPaste(event: ClipboardEvent) { + event.stopPropagation(); + event.preventDefault(); + + // Get pasted data via clipboard API + const clipboardData = event.clipboardData || (window as any).clipboardData; + const text = clipboardData.getData('Text'); + + this.updateAndValidate(text); + } + + import() { + const text = this.codeBlockElement.nativeElement.innerText; + + let saveFunc: Observable; + + if (this.dialogRef.data.type === 'setting') { + saveFunc = this.portapi.importSettings( + text, + this.dialogRef.data.key, + 'text/yaml', + this.reset, + this.allowUnknown + ); + } else { + saveFunc = this.portapi.importProfile( + text, + 'text/yaml', + this.reset, + this.allowUnknown, + this.allowReplace + ); + } + + saveFunc.subscribe({ + next: (result) => { + let msg = ''; + if (result.restartRequired) { + if (this.triggerRestart) { + this.portapi.restartPortmaster().subscribe(); + msg = 'Portmaster will be restarted now.'; + } else { + msg = 'Please restart Portmaster to apply the new settings.'; + } + } + + this.uai.success('Settings Imported Successfully', msg); + this.dialogRef.close(); + }, + error: (err) => { + this.uai.error( + 'Failed To Import Settings', + this.uai.getErrorMessgae(err) + ); + }, + }); + } + + updateAndValidate(content: string) { + const [start, end] = getSelectionOffset( + this.codeBlockElement.nativeElement + ); + + const p = (window as any).Prism; + const blob = p.highlight(content, p.languages.yaml, 'yaml'); + this.codeBlockElement.nativeElement.innerHTML = blob; + + setSelectionOffset(this.codeBlockElement.nativeElement, start, end); + + if (content === '') { + return; + } + + window.getSelection()?.removeAllRanges(); + + let validateFunc: Observable; + + if (this.dialogRef.data.type === 'setting') { + validateFunc = this.portapi.validateSettingsImport( + content, + this.dialogRef.data.key, + 'text/yaml' + ); + } else { + validateFunc = this.portapi.validateProfileImport(content, 'text/yaml'); + } + + validateFunc.subscribe({ + next: (result) => { + this.result = result; + this.errorMessage = ''; + + this.cdr.markForCheck(); + }, + error: (err) => { + const msg = this.uai.getErrorMessgae(err); + this.errorMessage = msg; + this.result = null; + + this.cdr.markForCheck(); + }, + }); + } + + loadFile(event: Event) { + const file: File = (event.target as any).files[0]; + if (!file) { + this.updateAndValidate(''); + + return; + } + + const reader = new FileReader(); + + reader.onload = (data) => { + (event.target as any).value = ''; + + let content = (data.target as any).result; + this.updateAndValidate(content); + }; + + reader.readAsText(file); + } +} diff --git a/desktop/angular/src/app/shared/config/import-dialog/selection.ts b/desktop/angular/src/app/shared/config/import-dialog/selection.ts new file mode 100644 index 00000000..e7018115 --- /dev/null +++ b/desktop/angular/src/app/shared/config/import-dialog/selection.ts @@ -0,0 +1,185 @@ +/** return true if node found */ +function searchNode( + container: Node, + startNode: Node, + predicate: (node: Node) => boolean, + excludeSibling?: boolean, +): boolean { + if (predicate(startNode as Text)) { + return true + } + + for (let i = 0, len = startNode.childNodes.length; i < len; i++) { + if (searchNode(startNode, startNode.childNodes[i], predicate, true)) { + return true + } + } + + if (!excludeSibling) { + let parentNode = startNode + while (parentNode && parentNode !== container) { + let nextSibling = parentNode.nextSibling + while (nextSibling) { + if (searchNode(container, nextSibling, predicate, true)) { + return true + } + nextSibling = nextSibling.nextSibling + } + parentNode = parentNode.parentNode! + } + } + + return false +} + +function createRange(container: Node, start: number, end: number): Range { + let startNode: any; + + searchNode(container, container, node => { + if (node.nodeType === Node.TEXT_NODE) { + const dataLength = (node as Text).data.length + if (start <= dataLength) { + startNode = node + return true + } + start -= dataLength + end -= dataLength + } + + return false + }) + + let endNode: any; + + if (startNode) { + searchNode(container, startNode, node => { + if (node.nodeType === Node.TEXT_NODE) { + const dataLength = (node as Text).data.length + if (end <= dataLength) { + endNode = node + return true + } + end -= dataLength + } + + return false + }) + } + + const range = document.createRange() + if (startNode) { + if (start < startNode.data.length) { + range.setStart(startNode, start) + } else { + range.setStartAfter(startNode) + } + } else { + if (start === 0) { + range.setStart(container, 0) + } else { + range.setStartAfter(container) + } + } + + if (endNode) { + if (end < endNode.data.length) { + range.setEnd(endNode, end) + } else { + range.setEndAfter(endNode) + } + } else { + if (end === 0) { + range.setEnd(container, 0) + } else { + range.setEndAfter(container) + } + } + + return range +} + +export function setSelectionOffset(node: Node, start: number, end: number) { + const range = createRange(node, start, end) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) +} + + +function getAbsoluteOffset(container: Node, offset: number) { + if (container.nodeType === Node.TEXT_NODE) { + return offset + } + + let absoluteOffset = 0 + for (let i = 0, len = Math.min(container.childNodes.length, offset); i < len; i++) { + const childNode = container.childNodes[i] + searchNode(childNode, childNode, node => { + if (node.nodeType === Node.TEXT_NODE) { + absoluteOffset += (node as Text).data.length + } + return false + }) + } + + return absoluteOffset +} + +export function getSelectionOffset(container: Node): [number, number] { + let start = 0 + let end = 0 + + const selection = window.getSelection()! + for (let i = 0, len = selection.rangeCount; i < len; i++) { + const range = selection.getRangeAt(i) + if (range.intersectsNode(container)) { + const startNode = range.startContainer + searchNode(container, container, node => { + if (startNode === node) { + start += getAbsoluteOffset(node, range.startOffset) + return true + } + + const dataLength = node.nodeType === Node.TEXT_NODE + ? (node as Text).data.length + : 0 + + start += dataLength + end += dataLength + + return false + }) + + const endNode = range.endContainer + searchNode(container, startNode, node => { + if (endNode === node) { + end += getAbsoluteOffset(node, range.endOffset) + return true + } + + const dataLength = node.nodeType === Node.TEXT_NODE + ? (node as Text).data.length + : 0 + + end += dataLength + + return false + }) + + break + } + } + + return [start, end] +} + +export function getInnerText(container: Node): string { + const buffer: any = [] + searchNode(container, container, node => { + if (node.nodeType === Node.TEXT_NODE) { + buffer.push((node as Text).data) + } + return false + }) + return buffer.join('') +} diff --git a/desktop/angular/src/app/shared/config/index.ts b/desktop/angular/src/app/shared/config/index.ts new file mode 100644 index 00000000..d71f0297 --- /dev/null +++ b/desktop/angular/src/app/shared/config/index.ts @@ -0,0 +1,8 @@ +export * from './basic-setting'; +export * from './config-settings'; +export * from './config.module'; +export * from './filter-lists'; +export * from './generic-setting'; +export * from './ordererd-list'; +export * from './rule-list'; +export * from './safe.pipe'; diff --git a/desktop/angular/src/app/shared/config/ordererd-list/index.ts b/desktop/angular/src/app/shared/config/ordererd-list/index.ts new file mode 100644 index 00000000..e8849b33 --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/index.ts @@ -0,0 +1,2 @@ +export { OrderedListComponent } from './ordered-list'; +export { OrderedListItemComponent } from './item'; \ No newline at end of file diff --git a/desktop/angular/src/app/shared/config/ordererd-list/item.html b/desktop/angular/src/app/shared/config/ordererd-list/item.html new file mode 100644 index 00000000..8550145b --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/item.html @@ -0,0 +1,14 @@ +
+ + {{value}} + + + + + + +
+ + +
+
diff --git a/desktop/angular/src/app/shared/config/ordererd-list/item.scss b/desktop/angular/src/app/shared/config/ordererd-list/item.scss new file mode 100644 index 00000000..169a61c5 --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/item.scss @@ -0,0 +1,56 @@ +:host { + @apply flex outline-none; + @apply space-x-2; + + &>* { + @apply rounded; + @apply bg-gray-300; + } +} + +div.value { + @apply border-gray-500 border; + @apply p-1; + @apply px-2; + + &.edit { + @apply p-0; + @apply bg-gray-400; + + input { + margin: 0; + width: auto; + flex-grow: 1; + border: none; + @apply shadow-none; + } + + input:focus+.buttons { + @apply bg-gray-500 border-gray-600 bg-opacity-75 border-opacity-75; + } + } + + flex-grow : 1; + display : flex; + justify-content: space-between; + align-items : center; + + .buttons { + flex-shrink: 0; + height: 100%; + width: 4rem; + @apply flex items-center justify-evenly; + + fa-icon { + cursor: pointer; + @apply text-primary; + @apply p-1; + opacity: 0.7; + font-size: 0.6rem; + + &:hover { + opacity: 1; + } + } + } +} diff --git a/desktop/angular/src/app/shared/config/ordererd-list/item.ts b/desktop/angular/src/app/shared/config/ordererd-list/item.ts new file mode 100644 index 00000000..eefb4e3b --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/item.ts @@ -0,0 +1,87 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +@Component({ + selector: 'app-ordered-list-item', + templateUrl: './item.html', + styleUrls: ['./item.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OrderedListItemComponent implements OnInit { + @Input() + set readonly(v: any) { + this._readonly = coerceBooleanProperty(v); + } + get readonly() { + return this._readonly; + } + private _readonly = false; + + @Input() + set value(v: string) { + this._value = v; + this._savedValue = v; + } + get value() { + return this._value; + } + _value = ''; + + private _savedValue = ''; + + @Output() + readonly valueChange = new EventEmitter(); + + @Output() + readonly delete = new EventEmitter(); + + @Input() + set edit(v: any) { + this._edit = coerceBooleanProperty(v); + } + get edit() { + return this._edit; + } + _edit = false; + + @Output() + readonly editChange = new EventEmitter(); + + ngOnInit() { + if (this._value === '' && this._savedValue === '') { + this.edit = true; + } + } + + toggleEdit() { + const wasEdit = this._edit; + this._edit = !wasEdit; + this.editChange.next(this._edit); + + if (!wasEdit) { + return; + } + + if (this._value !== this._savedValue) { + this._value = this._value.trim() + + this.valueChange.next(this.value); + this._savedValue = this._value; + } + this.changeDetectorRef.markForCheck(); + } + + reset() { + if (this._edit) { + if (this._value !== '' || this._savedValue !== '') { + this._value = this._savedValue; + this.changeDetectorRef.markForCheck(); + return; + } + } + + this.delete.next(); + } + + constructor(private changeDetectorRef: ChangeDetectorRef) { } +} diff --git a/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.html b/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.html new file mode 100644 index 00000000..fa043cb5 --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.html @@ -0,0 +1,23 @@ +
+
+ + + + + +
+
+ +
+ +
diff --git a/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.scss b/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.scss new file mode 100644 index 00000000..d4c1c086 --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.scss @@ -0,0 +1,77 @@ +:host { + outline: none; +} + +.item, +.cdk-drag-preview { + display: flex; + align-items: center; + padding: 3px; + + fa-icon { + cursor: pointer; + @apply text-tertiary; + @apply text-lg; + @apply mr-2; + } + + app-ordered-list-item { + flex-grow: 1; + } +} + +.cdk-drag-placeholder { + left: -4px; + padding: 1px; + padding-left: 4px; +} + +// TODO(ppacher9): move this transition to a mixin +.list-items.cdk-drop-list-dragging .list:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.cdk-drag-preview { + left: -4px; + padding: 1px; + padding-left: 4px; +} + +.button-list { + @apply mt-2; + @apply ml-8; +} + +.new-entry { + position: relative; + cursor: pointer; + @apply w-full; + @apply rounded; + @apply p-1; + @apply border-2; + @apply border-dashed; + @apply border-buttons-light; + @apply bg-background; + @apply text-secondary; + + span { + @apply font-medium; + } + + fa-icon { + font-size: 1rem; + } + + &:hover { + @apply text-primary; + @apply bg-cards-secondary; + + span { + @apply text-primary; + } + } + + display : flex; + align-items : center; + justify-content: center; +} diff --git a/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.ts b/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.ts new file mode 100644 index 00000000..0655ccb5 --- /dev/null +++ b/desktop/angular/src/app/shared/config/ordererd-list/ordered-list.ts @@ -0,0 +1,111 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostBinding, HostListener, Input } from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + + +@Component({ + selector: 'app-ordered-list', + templateUrl: './ordered-list.html', + styleUrls: ['./ordered-list.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OrderedListComponent), + multi: true, + } + ] +}) +export class OrderedListComponent implements ControlValueAccessor { + @HostBinding('tabindex') + readonly tabindex = 0; + + @HostListener('blur') + onBlur() { + this.onTouch(); + } + + @Input() + set readonly(v: any) { + this._readonly = coerceBooleanProperty(v); + } + get readonly() { + return this._readonly; + } + _readonly = false; + + @Input() + set fixedOrder(v: any) { + this._fixedOrder = coerceBooleanProperty(v); + } + get fixedOrder() { + return this._fixedOrder; + } + private _fixedOrder = false; + + entries: string[] = []; + + constructor(private changeDetector: ChangeDetectorRef) { } + + updateValue(index: number, newValue: string) { + // we need to make a new object copy here. + this.entries = [ + ...this.entries, + ]; + + this.entries[index] = newValue; + this.onChange(this.entries); + } + + deleteEntry(index: number) { + this.entries = [...this.entries]; + this.entries.splice(index, 1); + this.onChange(this.entries); + } + + addEntry() { + // if there's already one empty entry abort + if (this.entries.some(e => e.trim() === '')) { + return; + } + + this.entries = [...this.entries]; + this.entries.push(''); + //this.onChange(this.entries); + } + + writeValue(value: string[]) { + this.entries = value; + + this.changeDetector.markForCheck(); + } + + onChange = (_: string[]): void => { }; + registerOnChange(fn: (value: string[]) => void) { + this.onChange = fn; + } + + onTouch = (): void => { }; + registerOnTouched(fn: () => void) { + this.onTouch = fn; + } + + drop(event: CdkDragDrop) { + if (this._readonly) { + return; + } + + // create a copy of the array + this.entries = [...this.entries]; + moveItemInArray(this.entries, event.previousIndex, event.currentIndex); + + this.changeDetector.markForCheck(); + this.onChange(this.entries); + } + + trackBy(idx: number, value: string) { + return `${value}`; + } +} + diff --git a/desktop/angular/src/app/shared/config/rule-list/index.ts b/desktop/angular/src/app/shared/config/rule-list/index.ts new file mode 100644 index 00000000..a2d41fde --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/index.ts @@ -0,0 +1,2 @@ +export * from './list-item'; +export * from './rule-list'; diff --git a/desktop/angular/src/app/shared/config/rule-list/list-item.html b/desktop/angular/src/app/shared/config/rule-list/list-item.html new file mode 100644 index 00000000..34eeae30 --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/list-item.html @@ -0,0 +1,29 @@ +
+ + {{ symbolMap["+"] }} + {{ symbolMap["-"] }} + + + + + {{ symbolMap["+"] }} + {{ symbolMap["-"] }} + + +
+
+ + {{ display }} + + + + + + +
+ + + +
+ +
diff --git a/desktop/angular/src/app/shared/config/rule-list/list-item.scss b/desktop/angular/src/app/shared/config/rule-list/list-item.scss new file mode 100644 index 00000000..814d311b --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/list-item.scss @@ -0,0 +1,65 @@ +:host { + display: flex; + outline: none; + @apply space-x-2; + + &>* { + @apply rounded; + @apply bg-gray-300; + } +} + +div.action { + @apply border-gray-500 border; + flex-shrink: 0; + min-width: 6rem; + text-align: center; +} + +div.value { + @apply border-gray-500 border; + @apply p-1.5; + @apply px-2; + + &.edit { + @apply p-0; + @apply bg-gray-400; + + input { + margin: 0; + width: auto; + height: 100%; + flex-grow: 1; + border: none; + @apply shadow-none; + } + + input:focus+.buttons { + @apply bg-gray-500 border-gray-600 bg-opacity-75 border-opacity-75; + } + } + + flex-grow : 1; + display : flex; + justify-content: space-between; + align-items : center; + + .buttons { + flex-shrink: 0; + height: 100%; + width: 4rem; + @apply flex items-center justify-evenly; + + fa-icon { + cursor: pointer; + @apply text-primary; + @apply p-1; + opacity: 0.7; + font-size: 0.6rem; + + &:hover { + opacity: 1; + } + } + } +} diff --git a/desktop/angular/src/app/shared/config/rule-list/list-item.ts b/desktop/angular/src/app/shared/config/rule-list/list-item.ts new file mode 100644 index 00000000..09a6beaf --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/list-item.ts @@ -0,0 +1,221 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core'; +import { fadeInAnimation, fadeOutAnimation } from '../../animations'; + +@Component({ + selector: 'app-rule-list-item', + templateUrl: 'list-item.html', + styleUrls: ['list-item.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeOutAnimation + ] +}) +export class RuleListItemComponent implements OnInit { + /** The host element is going to fade in/out */ + @HostBinding('@fadeIn') + @HostBinding('@fadeOut') + readonly animation = true; + + @Input() + symbolMap: { [key: string]: string } = {} + + /** + * The current value (rule) displayed by this component. + * Supports two-way bindings. + */ + @Input() + set value(v: string) { + this.updateValue(v); + this._savedValue = this._value; + } + private _value = ''; + + /** The last actually saved value of this rule. Required for resets */ + private _savedValue = ''; + + /** + * Emits whenever the rule value changes. + * Supports two-way-bindings on ([value]) + */ + @Output() + valueChange = new EventEmitter(); + + /** Whether or not the rule list item is selected */ + @Input() + set selected(v: any) { + this._selected = coerceBooleanProperty(v) + } + get selected() { + return this._selected; + } + private _selected = false; + + @Output() + selectedChange = new EventEmitter(); + + /** + * Whether or not the component is in edit mode. + * Supports two-way-bindings on ([edit]) + */ + @Input() + set edit(v: any) { + this._edit = coerceBooleanProperty(v); + } + get edit() { + return this._edit; + } + private _edit: boolean = false; + + /** + * Emits whenever the component switch to or away from edit + * mode. + * Supports two-way-bindings on ([edit]) + */ + @Output() + editChange = new EventEmitter(); + + /** + * Whether or not the component should be in read-only mode. + */ + @Input() + set readonly(v: any) { + this._readonly = coerceBooleanProperty(v); + } + get readonly() { + return this._readonly; + } + private _readonly: boolean = false; + + /** + * Emits when the user presses the delete button of + * this rule component. + */ + @Output() + delete = new EventEmitter(); + + /** @private Whether or not this rule is a "Allow" rule - we default to allow since this is what most rules are used for */ + isAllow = true; + + /** @private Whether or not this rule is a "Deny" rule */ + isBlock = false; + + /** @private the actually displayed rule value (without the verdict) */ + display = ''; + + /** @private the character representation of the current verdict */ + get currentAction() { + if (this.isBlock) { + return '-'; + } + if (this.isAllow) { + return '+'; + } + return ''; + } + + constructor(private cdr: ChangeDetectorRef) { } + + ngOnInit() { + // new entries always start in edit mode + if (!this.isAllow && !this.isBlock) { + this._edit = true; + } + } + + /** + * @private + * Toggle between edit and view mode. When switching from + * edit to view mode, the current value is emitted to the + * parent element in case it has been changed. + */ + toggleEdit() { + if (this._edit) { + // do nothing if the rule is obviously invalid (no verdict or value). + if (this.display === '' || !(this.isAllow || this.isBlock)) { + return; + } + + if (this._value !== this._savedValue) { + this.valueChange.next(this._value); + } + } + + this._edit = !this._edit; + this.editChange.next(this._edit); + } + + toggleSelection() { + this.selected = !this.selected; + this.selectedChange.next(this.selected); + + this.cdr.markForCheck(); + } + + /** + * @private + * Sets the new rule action. Used as a callback in the drop-down. + * + * @param action The new action + */ + setAction(action: '+' | '-') { + this.updateValue(`${action} ${this.display}`); + } + + /** + * @private + * Update the actual value of the rule. + * + * @param entity The new rule value + */ + setEntity(entity: string) { + const action = this.isAllow ? '+' : '-'; + this.updateValue(`${action} ${entity}`); + } + + /** + * @private + * + * Reset the value to it's previously saved value if it was changed. + * If the value is unchanged a reset counts as a delete and triggers + * on our delete output. + */ + reset() { + if (this._edit) { + // if the user did not change anything we can immediately + // delete it. + if (this._savedValue !== '') { + this.value = this._savedValue; + this._edit = false; + return; + } + } + + this.delete.next(); + } + + /** + * Updates our internal states to correctly display the rule. + * + * @param v The actual rule value + */ + private updateValue(v: string) { + this._value = v.trim(); + switch (this._value[0]) { + case '+': + this.isAllow = true; + this.isBlock = false; + break; + case '-': + this.isAllow = false; + this.isBlock = true; + break; + default: + // not yet set + this.isBlock = this.isAllow = false; + } + + this.display = this._value.slice(1).trim(); + } +} diff --git a/desktop/angular/src/app/shared/config/rule-list/rule-list.html b/desktop/angular/src/app/shared/config/rule-list/rule-list.html new file mode 100644 index 00000000..3f7115c3 --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/rule-list.html @@ -0,0 +1,46 @@ +
+
+ + + + + +
+
+ +
+
+ No entries available +
+ + +
+ +
+ + + {{ selectedItems.length }} Rule{{ selectedItems.length > 1 ? 's' : ''}} selected + + + + + + + + Remove Rules + Cancel + +
diff --git a/desktop/angular/src/app/shared/config/rule-list/rule-list.scss b/desktop/angular/src/app/shared/config/rule-list/rule-list.scss new file mode 100644 index 00000000..23bf7034 --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/rule-list.scss @@ -0,0 +1,75 @@ +:host { + outline: none; +} + +.item, +.cdk-drag-preview { + display: flex; + align-items: center; + padding: 3px; + + fa-icon { + cursor: pointer; + @apply text-tertiary; + @apply text-lg; + @apply mr-2; + } + + app-rule-list-item { + flex-grow: 1; + } +} + +.cdk-drag-placeholder { + left: -4px; + padding: 1px; + padding-left: 4px; +} + +// TODO(ppacher9): move this transition to a mixin +.list-items.cdk-drop-list-dragging .list:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.cdk-drag-preview { + left: -4px; + padding: 1px; + padding-left: 4px; +} + +.button-list { + @apply mt-2; + @apply ml-8; +} + +.dotted { + @apply w-full; + @apply rounded; + @apply p-1; + @apply border-2; + @apply border-dashed; + @apply border-buttons-light; + @apply bg-background; + @apply text-secondary; + + display: flex; + align-items: center; + justify-content: center; + + span { + @apply font-medium; + } +} + +.new-entry { + cursor: pointer; + + &:hover { + @apply text-primary; + @apply bg-gray-300; + + span { + @apply text-primary; + } + } +} diff --git a/desktop/angular/src/app/shared/config/rule-list/rule-list.ts b/desktop/angular/src/app/shared/config/rule-list/rule-list.ts new file mode 100644 index 00000000..f5ac6c86 --- /dev/null +++ b/desktop/angular/src/app/shared/config/rule-list/rule-list.ts @@ -0,0 +1,226 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostBinding, HostListener, Input, QueryList, ViewChildren } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { SfngDialogService } from '@safing/ui'; +import { RuleListItemComponent } from './list-item'; + +@Component({ + selector: 'app-rule-list', + templateUrl: './rule-list.html', + styleUrls: ['./rule-list.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleListComponent), + multi: true, + } + ], +}) +export class RuleListComponent implements ControlValueAccessor { + /** Add the host element into the tab-sequence */ + @HostBinding('tabindex') + readonly tabindex = 0; + + @ViewChildren(RuleListItemComponent) + renderedRules!: QueryList; + + /** A list of selected rule indexes */ + selectedItems: number[] = []; + + /** + * @private + * Mark the component as dirty by calling the onTouch callback of the control-value accessor + */ + @HostListener('blur') + onBlur() { + this.onTouch(); + } + + @Input() + symbolMap = { + '+': 'Allow', + '-': 'Block', + } + + /** + * Whether or not the component should be displayed as read-only. + */ + @Input() + set readonly(v: any) { + this._readonly = coerceBooleanProperty(v); + } + get readonly() { + return this._readonly; + } + private _readonly = false; + + /** + * @private + * The actual rule entries. Displayed as RuleListItemComponent. + */ + entries: string[] = []; + + constructor( + private changeDetector: ChangeDetectorRef, + private dialog: SfngDialogService + ) { } + + /** + * @private + * Update the value of a rule-list entry. Used as a callback function + * for the valueChange output of the RuleListItemComponent. + * + * @param index The index of the rule list entry to update + * @param newValue The new value of the rule + */ + updateValue(index: number, newValue: string) { + // we need create a copy of the actual value as + // the parent component might still have a reference + // to the current values. + this.entries = [ + ...this.entries, + ]; + this.entries[index] = newValue; + + // tell the control that we have a new value + this.onChange(this.entries); + } + + /** + * @private + * Delete a rule list entry. + * + * @param index The index of the rule list entry to delete + */ + deleteEntry(index: number) { + this.entries = [...this.entries]; + this.entries.splice(index, 1); + this.onChange(this.entries); + } + + /** + * @private + * Add a new, empty rule list entry at the end of the + * list. + * + * This is a no-op if there's already an empty item + * available. + */ + addEntry() { + // if there's already one empty entry abort + if (this.entries.some(e => e.trim() === '')) { + return; + } + + this.entries = [...this.entries]; + this.entries.push(''); + } + + /** + * Set a new value for the rule list. This is the + * only way to configure the existing entries and is + * used by the control-value-accessor and ngModel. + * + * @param value The new value set via [ngModel] + */ + writeValue(value: string[]) { + this.entries = value; + + this.changeDetector.markForCheck(); + } + + /** Toggles selection of a rule item */ + selectItem(index: number, selected: boolean) { + if (selected && !this.selectedItems.includes(index)) { + this.selectedItems = [ + ...this.selectedItems, + index, + ] + + return; + } + + if (!selected && this.selectedItems.includes(index)) { + this.selectedItems = this.selectedItems.filter(idx => idx !== index) + + return; + } + } + + /** Removes all selected items after displaying a confirmation dialog. */ + removeSelectedItems() { + this.dialog.confirm({ + buttons: [ + { + id: 'abort', + text: 'Cancel', + class: 'outline' + }, + { + id: 'delete', + text: 'Delete Rules', + class: 'danger' + } + ], + canCancel: true, + caption: 'Caution', + header: 'Rule Deletion', + message: 'Do you want to delete the selected rules' + }) + .onAction('delete', () => { + this.entries = this.entries.filter((_, idx: number) => !this.selectedItems.includes(idx)) + this.abortSelection(); + this.onChange(this.entries); + }) + + } + + /** Aborts the current selection */ + abortSelection() { + this.selectedItems.forEach(itemIdx => this.renderedRules.get(itemIdx)?.toggleSelection()) + this.selectedItems = []; + } + + /** @private onChange callback registered by ngModel and form controls */ + onChange = (_: string[]): void => { }; + + /** Registers the onChange callback and required for the ControlValueAccessor interface */ + registerOnChange(fn: (value: string[]) => void) { + this.onChange = fn; + } + + /** @private onTouch callback registered by ngModel and form controls */ + onTouch = (): void => { }; + + /** Registers the onChange callback and required for the ControlValueAccessor interface */ + registerOnTouched(fn: () => void) { + this.onTouch = fn; + } + + /** + * @private + * Used as a callback for the @angular/cdk drop component + * and used to update the actual order of the entries. + * + * @param event The drop-event + */ + drop(event: CdkDragDrop) { + if (this._readonly) { + return; + } + + // create a copy of the array + this.entries = [...this.entries]; + moveItemInArray(this.entries, event.previousIndex, event.currentIndex); + + this.changeDetector.markForCheck(); + this.onChange(this.entries); + } + + /** @private TrackByFunction for entries */ + trackBy(idx: number, value: string) { + return `${value}`; + } +} diff --git a/desktop/angular/src/app/shared/config/safe.pipe.ts b/desktop/angular/src/app/shared/config/safe.pipe.ts new file mode 100644 index 00000000..0cbf2855 --- /dev/null +++ b/desktop/angular/src/app/shared/config/safe.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser'; + +@Pipe({ + name: 'safe' +}) +export class SafePipe implements PipeTransform { + + constructor(protected sanitizer: DomSanitizer) { } + + public transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl { + switch (type) { + case 'html': return this.sanitizer.bypassSecurityTrustHtml(value); + case 'style': return this.sanitizer.bypassSecurityTrustStyle(value); + case 'script': return this.sanitizer.bypassSecurityTrustScript(value); + case 'url': return this.sanitizer.bypassSecurityTrustUrl(value); + case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value); + default: throw new Error(`Invalid safe type specified: ${type}`); + } + } +} diff --git a/desktop/angular/src/app/shared/count-indicator/count-indicator.html b/desktop/angular/src/app/shared/count-indicator/count-indicator.html new file mode 100644 index 00000000..fdbb0c22 --- /dev/null +++ b/desktop/angular/src/app/shared/count-indicator/count-indicator.html @@ -0,0 +1,4 @@ +{{ count | prettyCount }} +
+
+
diff --git a/desktop/angular/src/app/shared/count-indicator/count-indicator.module.ts b/desktop/angular/src/app/shared/count-indicator/count-indicator.module.ts new file mode 100644 index 00000000..0961a129 --- /dev/null +++ b/desktop/angular/src/app/shared/count-indicator/count-indicator.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from "@angular/core"; +import { CountIndicatorComponent } from "./count-indicator"; +import { PrettyCountPipe } from "./count.pipe"; + +@NgModule({ + declarations: [ + CountIndicatorComponent, + PrettyCountPipe, + ], + exports: [ + CountIndicatorComponent, + PrettyCountPipe, + ] +}) +export class CountIndicatorModule { } diff --git a/desktop/angular/src/app/shared/count-indicator/count-indicator.scss b/desktop/angular/src/app/shared/count-indicator/count-indicator.scss new file mode 100644 index 00000000..3d97d2c9 --- /dev/null +++ b/desktop/angular/src/app/shared/count-indicator/count-indicator.scss @@ -0,0 +1,8 @@ +@import '../../../theme/mixins/_pill.scss'; + +:host { + @include pill-container; + @apply pl-2; + @apply bg-buttons-dark; + @apply w-20; +} diff --git a/desktop/angular/src/app/shared/count-indicator/count-indicator.ts b/desktop/angular/src/app/shared/count-indicator/count-indicator.ts new file mode 100644 index 00000000..8c49e098 --- /dev/null +++ b/desktop/angular/src/app/shared/count-indicator/count-indicator.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +@Component({ + selector: 'app-count-indicator', + templateUrl: './count-indicator.html', + styleUrls: ['./count-indicator.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CountIndicatorComponent implements OnChanges { + @Input() + count = 0; + + @Input() + countAllowed: number = 0; + + allowedPercentage: number = 0; + + ngOnChanges() { + const ratio = (this.countAllowed / this.count) || 0; + this.allowedPercentage = Math.round(ratio * 100); + } +} diff --git a/desktop/angular/src/app/shared/count-indicator/count.pipe.ts b/desktop/angular/src/app/shared/count-indicator/count.pipe.ts new file mode 100644 index 00000000..69140b3e --- /dev/null +++ b/desktop/angular/src/app/shared/count-indicator/count.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'prettyCount', + pure: true +}) +export class PrettyCountPipe implements PipeTransform { + transform(value: number) { + if (value > 999) { + const v = Math.floor(value / 1000); + if (value === v * 1000) { + return `${v}k`; + } + return `${v}k+` + } + return `${value}` + } +} diff --git a/desktop/angular/src/app/shared/count-indicator/index.ts b/desktop/angular/src/app/shared/count-indicator/index.ts new file mode 100644 index 00000000..be4276b7 --- /dev/null +++ b/desktop/angular/src/app/shared/count-indicator/index.ts @@ -0,0 +1,2 @@ +export * from './count-indicator'; +export * from './count-indicator.module'; diff --git a/desktop/angular/src/app/shared/country-flag/country-flag.ts b/desktop/angular/src/app/shared/country-flag/country-flag.ts new file mode 100644 index 00000000..df91a3c5 --- /dev/null +++ b/desktop/angular/src/app/shared/country-flag/country-flag.ts @@ -0,0 +1,45 @@ +import { AfterViewInit, Directive, ElementRef, HostBinding, Input, OnChanges, Renderer2, SimpleChanges } from '@angular/core'; + +@Directive({ + selector: 'span[appCountryFlags]', +}) +export class CountryFlagDirective implements AfterViewInit, OnChanges { + private readonly flagDir = "/assets/img/flags/"; + private readonly OFFSET = 127397; + + @HostBinding('style.text-shadow') + textShadow = 'rgba(255, 255, 255, .5) 0px 0px 1px'; + + @Input() + appCountryFlags: string = ''; + + constructor( + private el: ElementRef, + private renderer: Renderer2 + ) { } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes['appCountryFlags'].isFirstChange()) { + this.update(); + } + } + + ngAfterViewInit() { + this.update(); + } + + private update() { + const span = this.el.nativeElement as HTMLSpanElement; + const flag = this.toUnicodeFlag(this.appCountryFlags); + this.renderer.setAttribute(span, 'data-before', flag); + + span.innerHTML = ``; + } + + private toUnicodeFlag(code: string) { + const base = 127462 - 65; + const cc = code.toUpperCase(); + const res = String.fromCodePoint(...cc.split('').map(c => base + c.charCodeAt(0))); + return res; + } +} diff --git a/desktop/angular/src/app/shared/country-flag/country.module.ts b/desktop/angular/src/app/shared/country-flag/country.module.ts new file mode 100644 index 00000000..2acdb3f8 --- /dev/null +++ b/desktop/angular/src/app/shared/country-flag/country.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CountryFlagDirective } from './country-flag'; + +@NgModule({ + declarations: [ + CountryFlagDirective + ], + exports: [ + CountryFlagDirective, + ] +}) +export class CountryFlagModule { } diff --git a/desktop/angular/src/app/shared/country-flag/index.ts b/desktop/angular/src/app/shared/country-flag/index.ts new file mode 100644 index 00000000..cc7d4306 --- /dev/null +++ b/desktop/angular/src/app/shared/country-flag/index.ts @@ -0,0 +1,2 @@ +export * from './country-flag'; +export * from './country.module'; diff --git a/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.html b/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.html new file mode 100644 index 00000000..7b630621 --- /dev/null +++ b/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.html @@ -0,0 +1,322 @@ +

+ + + {{ isEditMode ? 'Edit App Profile' : 'Create New App Profile' }} +

+ +
+ + +
+ + Configure basic profile information like the profile name, it's + description and optionally the profile icon. + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+ + +
+ The icon must be smaller than 10kB and it's dimensions must not + exceed 512x512 px. Only JPG and PNG files are supported. +
+ + {{ imageError }} +
+ + +
+
+
+ + +
+ This profile will be applied to processes that match one of the + following fingerprints: + +
+ No fingerprints configured. Please press "Add New" to get started. +
+
+
+ + + + + + + + + Tag + + + Command Line + + Environment + + Path + + + + + + + {{ tag.Name }} + + + + + Equals + Prefix + Regex + + + + + +
+
+ + +
+
+ + +
+
+ Select a Profile to copy settings from: +
+ + + + + {{ p.Name }} + + + + +
+ +
+
+ + + + + {{ p.Name }} +
+ +
+
+ + Settings will be copied from all specified profiles in order with + settings from higher profiles taking precedence.
+ Existing settings may be overwritten. +
+
+
+
+
+ +
+ +
+ + +
diff --git a/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.scss b/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.scss new file mode 100644 index 00000000..03e9ccea --- /dev/null +++ b/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.scss @@ -0,0 +1,29 @@ +:host { + @apply flex flex-col gap-4 max-w-2xl; + min-width: 500px; + width: 60vw; +} + +.tab-content { + @apply flex flex-col gap-4 overflow-x-hidden h-96 pt-2; +} + +.input { + @apply flex flex-col gap-1; + + label { + @apply text-primary uppercase text-xxs relative left-1.5; + } + + input[type="text"] { + @apply border border-gray-500; + + &.ng-invalid.ng-dirty { + @apply border-red-200; + } + } + + input[type="file"] { + display: none; + } +} diff --git a/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts b/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts new file mode 100644 index 00000000..60b5b514 --- /dev/null +++ b/desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts @@ -0,0 +1,393 @@ +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit, + TrackByFunction, +} from '@angular/core'; +import { + AppProfile, + AppProfileService, + FingerpringOperation, + Fingerprint, + FingerprintType, + PORTMASTER_HTTP_API_ENDPOINT, + PortapiService, + Record, + TagDescription, + mergeDeep, +} from '@safing/portmaster-api'; +import { SFNG_DIALOG_REF, SfngDialogRef, SfngDialogService } from '@safing/ui'; +import { Observable, Subject, map, of, switchMap, takeUntil } from 'rxjs'; +import { ActionIndicatorService } from 'src/app/shared/action-indicator'; + +@Component({ + templateUrl: './edit-profile-dialog.html', + //changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./edit-profile-dialog.scss'], +}) +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class EditProfileDialog implements OnInit, OnDestroy { + private destory$ = new Subject(); + + profile: Partial = { + ID: '', + Source: 'local', + Name: '', + Description: '', + Icons: [], + Fingerprints: [], + }; + + isEditMode = false; + iconData: string | ArrayBuffer = ''; + iconType: string = ''; + iconChanged = false; + iconObjectURL = ''; + imageError: string | null = null; + + allProfiles: AppProfile[] = []; + + copySettingsFrom: AppProfile[] = []; + + selectedCopyFrom: AppProfile | null = null; + + fingerPrintTypes = FingerprintType; + fingerPrintOperations = FingerpringOperation; + processTags: TagDescription[] = []; + + trackFingerPrint: TrackByFunction = ( + _: number, + fp: Fingerprint + ) => `${fp.Type}-${fp.Key}-${fp.Operation}-${fp.Value}`; + + constructor( + @Inject(SFNG_DIALOG_REF) + private dialgoRef: SfngDialogRef< + EditProfileDialog, + any, + string | null | AppProfile + >, + private profileService: AppProfileService, + private portapi: PortapiService, + private actionIndicator: ActionIndicatorService, + private dialog: SfngDialogService, + private cdr: ChangeDetectorRef, + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string + ) { } + + ngOnInit(): void { + this.profileService.tagDescriptions().subscribe((result) => { + this.processTags = result; + this.cdr.markForCheck(); + }); + + this.profileService + .watchProfiles() + .pipe(takeUntil(this.destory$)) + .subscribe((profiles) => { + this.allProfiles = profiles; + this.cdr.markForCheck(); + }); + + if (!!this.dialgoRef.data && typeof this.dialgoRef.data === 'string') { + this.isEditMode = true; + this.profileService + .getAppProfile(this.dialgoRef.data) + .subscribe((profile) => { + this.profile = profile; + this.loadIcon(); + }); + } else if ( + !!this.dialgoRef.data && + typeof this.dialgoRef.data === 'object' + ) { + this.profile = this.dialgoRef.data; + this.loadIcon(); + } + } + + private loadIcon() { + if (!this.profile.Icons?.length) { + return; + } + + const firstIcon = this.profile.Icons[0]; + + // get the current icon of the profile + switch (firstIcon.Type) { + case 'database': + this.portapi + .get(firstIcon.Value) + .subscribe((data) => { + this.iconData = data.iconData; + this.iconObjectURL = this.iconData; + this.cdr.markForCheck(); + }); + break; + + case 'api': + this.iconData = `${this.httpAPI}/v1/profile/icon/${firstIcon.Value}`; + this.iconObjectURL = this.iconData; + + break; + + default: + console.error(`Unsupported icon type ${firstIcon.Type}`); + } + + this.cdr.markForCheck(); + } + + ngOnDestroy() { + this.destory$.next(); + this.destory$.complete(); + } + + addFingerprint() { + this.profile.Fingerprints?.push({ + Key: '', + Operation: FingerpringOperation.Equal, + Value: '', + Type: FingerprintType.Path, + }); + } + + removeFingerprint(idx: number) { + this.profile.Fingerprints?.splice(idx, 1); + this.profile.Fingerprints = [...this.profile.Fingerprints!]; + } + + removeCopyFrom(idx: number) { + this.copySettingsFrom.splice(idx, 1); + this.copySettingsFrom = [...this.copySettingsFrom]; + } + + addCopyFrom() { + this.copySettingsFrom = [...this.copySettingsFrom, this.selectedCopyFrom!]; + this.selectedCopyFrom = null; + } + + drop(event: CdkDragDrop) { + // create a copy of the array + this.copySettingsFrom = [...this.copySettingsFrom]; + moveItemInArray( + this.copySettingsFrom, + event.previousIndex, + event.currentIndex + ); + + this.cdr.markForCheck(); + } + + deleteProfile() { + this.dialog + .confirm({ + caption: 'Caution', + header: 'Confirm Profile Deletion', + message: 'Do you want to delete this profile?', + buttons: [ + { + id: 'delete', + class: 'danger', + text: 'Delete', + }, + { + id: 'abort', + class: 'outline', + text: 'Cancel', + }, + ], + }) + .onAction('delete', () => { + this.profileService + .deleteProfile(this.profile as AppProfile) + .subscribe({ + next: () => this.dialgoRef.close('deleted'), + error: (err) => { + this.actionIndicator.error('Failed to delete profile', err); + }, + }); + }); + } + + resetIcon() { + this.iconChanged = true; + this.iconData = ''; + this.iconType = ''; + this.iconObjectURL = ''; + } + + save() { + if (!this.profile.ID) { + this.profile.ID = this.uuidv4(); + } + + if (!this.profile.Source) { + this.profile.Source = 'local'; + } + + let updateIcon: Observable = of(undefined); + + if (this.iconChanged) { + // delete any previously set icon + this.profile.Icons?.forEach((icon) => { + if (icon.Type === 'database') { + this.portapi.delete(icon.Value).subscribe(); + } + + // FIXME(ppacher): we cannot yet delete API based icons ... + }); + + if (this.iconData !== '') { + // save the new icon in the cache database + + // FIXME(ppacher): we currently need to calls because the icon API in portmaster + // does not update the profile but just saves the file and returns the filename. + // So we still need to update the profile manually. + updateIcon = this.profileService + .setProfileIcon(this.iconData, this.iconType) + .pipe( + map(({ filename }) => { + this.profile.Icons = [ + { + Type: 'api', + Value: filename, + Source: 'user', + }, + ]; + }) + ); + + // FIXME(ppacher): reset presentationpath + } else { + // just clear out that there was an icon + this.profile.Icons = []; + } + } + + if (this.profile.Fingerprints!.length > 1) { + this.profile.PresentationPath = ''; + } + const oldConfig = this.profile.Config || {}; + this.profile.Config = {}; + + mergeDeep( + this.profile.Config, + ...[...this.copySettingsFrom.map((p) => p.Config || {}), oldConfig] + ); + + updateIcon + .pipe( + switchMap(() => { + return this.profileService.saveProfile(this.profile as AppProfile); + }) + ) + .subscribe({ + next: () => { + this.actionIndicator.success( + this.profile.Name!, + 'Profile saved successfully' + ); + this.dialgoRef.close('saved'); + }, + error: (err) => { + this.actionIndicator.error('Failed to save profile', err); + }, + }); + } + + abort() { + this.dialgoRef.close('abort'); + } + + fileChangeEvent(fileInput: any) { + this.imageError = null; + this.iconData = ''; + this.iconChanged = true; + + if (fileInput.target.files && fileInput.target.files[0]) { + const max_size = 10 * 1024; + const allowed_types = [ + 'image/png', + 'image/jpeg', + 'image/svg', + 'image/gif', + 'image/tiff', + ]; + const max_height = 512; + const max_width = 512; + const file: File = fileInput.target.files[0]; + + if (file.size > max_size) { + this.imageError = 'Maximum size allowed is ' + max_size / 1000 + 'KB'; + } + + if (!allowed_types.includes(file.type)) { + this.imageError = 'Only JPG, PNG, SVG, GIF or Tiff files are allowed'; + } + + this.iconType = file.type; + + const reader = new FileReader(); + reader.onload = (e: ProgressEvent) => { + const content: ArrayBuffer = e.target!.result! as ArrayBuffer; + const blob = new Blob([content], { type: file.type }); + + const image = new Image(); + image.src = URL.createObjectURL(blob); + this.iconObjectURL = image.src; + + image.onload = (rs: any) => { + const img_height = rs.currentTarget['height']!; + const img_width = rs.currentTarget['width']; + + if (img_height > max_height && img_width > max_width) { + this.imageError = + 'Maximum dimentions allowed ' + + max_height + + '*' + + max_width + + 'px'; + } else { + this.iconData = content; + } + + this.cdr.markForCheck(); + }; + + image.onerror = (err: any) => { + this.actionIndicator.error( + 'Failed to get image', + this.actionIndicator.getErrorMessgae(err) + ); + }; + + this.cdr.markForCheck(); + }; + + reader.onerror = (err: any) => { + this.actionIndicator.error( + 'Failed to get image', + this.actionIndicator.getErrorMessgae(err) + ); + }; + + reader.readAsArrayBuffer(fileInput.target.files[0]); + } + } + + private uuidv4(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + // This one is not really random and not RFC compliant but serves enough for fallback + // purposes if the UI is opened in a browser that does not yet support randomUUID + console.warn('Using browser with lacking support for crypto.randomUUID()'); + + return Date.now().toString(36) + Math.random().toString(36).substring(2); + } +} diff --git a/desktop/angular/src/app/shared/edit-profile-dialog/index.ts b/desktop/angular/src/app/shared/edit-profile-dialog/index.ts new file mode 100644 index 00000000..0a4c617d --- /dev/null +++ b/desktop/angular/src/app/shared/edit-profile-dialog/index.ts @@ -0,0 +1 @@ +export * from './edit-profile-dialog'; diff --git a/desktop/angular/src/app/shared/exit-screen/exit-screen.html b/desktop/angular/src/app/shared/exit-screen/exit-screen.html new file mode 100644 index 00000000..cff2a960 --- /dev/null +++ b/desktop/angular/src/app/shared/exit-screen/exit-screen.html @@ -0,0 +1,19 @@ +
+ Tip + + +

Close User Interface

+ + Closing the User Interface does not shut down the Portmaster. You can shut down the Portmaster + in the Settings or the Tray Notifier. + +
+ + + + + +
+
diff --git a/desktop/angular/src/app/shared/exit-screen/exit-screen.scss b/desktop/angular/src/app/shared/exit-screen/exit-screen.scss new file mode 100644 index 00000000..65e5dc8b --- /dev/null +++ b/desktop/angular/src/app/shared/exit-screen/exit-screen.scss @@ -0,0 +1,68 @@ +caption { + @apply text-sm; + opacity : .6; + font-size: .6rem; +} + +.content-wrapper { + display : flex; + flex-direction: column; + align-items : flex-start; + + h1 { + font-size : 0.85rem; + font-weight : 500; + margin-bottom: 1rem; + } + + .message, + h1 { + flex-shrink : 0; + text-overflow: ellipsis; + word-break : normal; + } + + .message { + font-size: 0.75rem; + flex-grow: 1; + opacity : .6; + } + + .close-icon { + position: absolute; + top : 1rem; + right : 1rem; + opacity : .7; + cursor : pointer; + + &:hover { + opacity: 1; + } + } + + .actions { + margin-top : 1rem; + width : 100%; + display : flex; + justify-content: space-between; + align-items : center; + + button { + @apply bg-info-blue; + + &.danger { + @apply bg-info-red; + } + } + + &>span { + display : flex; + align-items: center; + + label { + margin-left: .5rem; + user-select: none; + } + } + } +} diff --git a/desktop/angular/src/app/shared/exit-screen/exit-screen.ts b/desktop/angular/src/app/shared/exit-screen/exit-screen.ts new file mode 100644 index 00000000..3dbda654 --- /dev/null +++ b/desktop/angular/src/app/shared/exit-screen/exit-screen.ts @@ -0,0 +1,52 @@ +import { OverlayRef } from '@angular/cdk/overlay'; +import { Component, Inject, InjectionToken } from '@angular/core'; +import { SfngDialogRef, SFNG_DIALOG_REF } from '@safing/ui'; +import { Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { UIStateService } from 'src/app/services'; +import { fadeInAnimation, fadeOutAnimation } from '../animations'; + +export const OVERLAYREF = new InjectionToken('OverlayRef'); + +@Component({ + templateUrl: './exit-screen.html', + styleUrls: ['./exit-screen.scss'], + animations: [ + fadeInAnimation, + fadeOutAnimation, + ] +}) +export class ExitScreenComponent { + constructor( + @Inject(SFNG_DIALOG_REF) private _dialogRef: SfngDialogRef, + private stateService: UIStateService, + ) { } + + /** @private - used as ngModel form the template */ + neveragain: boolean = false; + + closeUI() { + const closeObserver = { + next: () => { + this._dialogRef.close('exit'); + } + } + + let close: Observable = of(null); + if (this.neveragain) { + close = this.stateService.uiState() + .pipe( + map(state => { + state.hideExitScreen = true; + return state; + }), + switchMap(state => this.stateService.saveState(state)), + ) + } + close.subscribe(closeObserver) + } + + cancel() { + this._dialogRef.close() + } +} diff --git a/desktop/angular/src/app/shared/exit-screen/exit.service.ts b/desktop/angular/src/app/shared/exit-screen/exit.service.ts new file mode 100644 index 00000000..c1da5b92 --- /dev/null +++ b/desktop/angular/src/app/shared/exit-screen/exit.service.ts @@ -0,0 +1,146 @@ +import { IntegrationService } from './../../integration/integration'; +import { Injectable, inject } from '@angular/core'; +import { PortapiService } from '@safing/portmaster-api'; +import { SfngDialogService } from '@safing/ui'; +import { BehaviorSubject, merge, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, map, skip, switchMap, tap, timeout } from 'rxjs/operators'; +import { UIStateService } from 'src/app/services'; +import { ActionIndicatorService } from '../action-indicator'; +import { ExitScreenComponent } from './exit-screen'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; + +const MessageConnecting = 'Connecting to Portmaster'; +const MessageShutdown = 'Shutting Down Portmaster'; +const MessageRestart = 'Restarting Portmaster'; +const MessageHidden = ''; + +export type OverlayMessage = typeof MessageConnecting + | typeof MessageShutdown + | typeof MessageRestart + | typeof MessageHidden; + +@Injectable({ providedIn: 'root' }) +export class ExitService { + private integration = inject(INTEGRATION_SERVICE); + + private hasOverlay = false; + + private _showOverlay = new BehaviorSubject(MessageConnecting); + + /** + * Emits whenever the "Connecting to ..." or "Restarting ..." overlays + * should be shown. It actually emits the message that should be shown. + * An empty string indicates the overlay should be closed. + */ + get showOverlay$() { return this._showOverlay.asObservable() } + + constructor( + private stateService: UIStateService, + private portapi: PortapiService, + private dialog: SfngDialogService, + private uai: ActionIndicatorService, + ) { + + this.portapi.connected$ + .pipe( + distinctUntilChanged(), + ) + .subscribe(connected => { + if (connected) { + this._showOverlay.next(MessageHidden); + } else if (this._showOverlay.getValue() !== MessageShutdown) { + this._showOverlay.next(MessageConnecting) + } + }) + + + let restartInProgress = false; + merge( + this.portapi.sub('runtime:modules/core/event/shutdown') + .pipe(map(() => MessageShutdown)), + this.portapi.sub('runtime:modules/core/event/restart') + .pipe( + tap(() => restartInProgress = true), + map(() => MessageRestart) + ), + ) + .pipe( + tap(msg => this._showOverlay.next(msg)), + switchMap(() => this.portapi.connected$), + distinctUntilChanged(), + skip(1), + debounceTime(1000), // make sure we display the "shutdown" overlay for at least a second + ) + .subscribe(connected => { + if (this._showOverlay.getValue() === MessageShutdown) { + setTimeout(() => { + this.integration.exitApp(); + }, 1000) + } + + if (connected && restartInProgress) { + restartInProgress = false; + this.portapi.reloadUI() + .pipe( + tap(() => { + setTimeout(() => window.location.reload(), 1000) + }) + ) + .subscribe(this.uai.httpObserver( + 'Reloading UI ...', + 'Failed to Reload UI', + )) + } + }) + + window.addEventListener('beforeunload', () => { + // best effort. may not work all the time depending on + // the current websocket buffer state + this.portapi.bridgeAPI('ui/reload', 'POST').subscribe(); + }) + + this.integration.onExitRequest(() => { + this.stateService.uiState() + // make sure to not wait for the portmaster to start + .pipe(timeout(1000), catchError(() => of(null))) + .subscribe(state => { + if (state?.hideExitScreen) { + this.integration.exitApp(); + return + } + + if (this.hasOverlay) { + return; + } + this.hasOverlay = true; + + this.dialog.create(ExitScreenComponent, { autoclose: true }) + .onAction('exit', () => this.integration.exitApp()) + .onClose.subscribe(() => this.hasOverlay = false); + }) + }) + } + + shutdownPortmaster() { + this.dialog.confirm({ + canCancel: true, + header: 'Shutting Down Portmaster', + message: 'Shutting down the Portmaster will stop all Portmaster components and will leave your system unprotected!', + caption: 'Caution', + buttons: [ + { + id: 'shutdown', + class: 'danger', + text: 'Shut Down Portmaster' + } + ] + }) + .onAction('shutdown', () => { + this.portapi.shutdownPortmaster() + .subscribe(this.uai.httpObserver( + 'Shutting Down ...', + 'Failed to Shut Down', + )) + }) + } +} diff --git a/desktop/angular/src/app/shared/exit-screen/index.ts b/desktop/angular/src/app/shared/exit-screen/index.ts new file mode 100644 index 00000000..8ea5634d --- /dev/null +++ b/desktop/angular/src/app/shared/exit-screen/index.ts @@ -0,0 +1,2 @@ +export * from './exit.service'; +export * from './exit-screen'; diff --git a/desktop/angular/src/app/shared/expertise/expertise-directive.ts b/desktop/angular/src/app/shared/expertise/expertise-directive.ts new file mode 100644 index 00000000..379466af --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/expertise-directive.ts @@ -0,0 +1,93 @@ +import { Directive, EmbeddedViewRef, Input, isDevMode, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core'; +import { ExpertiseLevelNumber } from '@safing/portmaster-api'; +import { Subscription } from 'rxjs'; +import { ExpertiseService } from './expertise.service'; + +// ExpertiseLevelOverwrite may be called to display a DOM node decorated +// with [appExpertiseLevel] even if the current user setting does not +// match the required expertise. +export type ExpertiseLevelOverwrite = (lvl: ExpertiseLevelNumber, data: T) => boolean; +@Directive({ + selector: '[appExpertiseLevel]', +}) +export class ExpertiseDirective implements OnInit, OnDestroy { + private allowedValue: ExpertiseLevelNumber = ExpertiseLevelNumber.user; + private subscription = Subscription.EMPTY; + private view: EmbeddedViewRef | null = null; + + @Input() + set appExpertiseLevelOverwrite(fn: ExpertiseLevelOverwrite) { + this._levelOverwriteFn = fn; + this.update(); + } + private _levelOverwriteFn: ExpertiseLevelOverwrite | null = null; + + @Input() + set appExpertiseLevelData(d: T) { + this._data = d; + this.update(); + } + private _data: T | undefined = undefined; + + @Input() + set appExpertiseLevel(lvl: ExpertiseLevelNumber | string) { + if (typeof lvl === 'string') { + lvl = ExpertiseLevelNumber[lvl as any]; + } + if (lvl === undefined) { + if (isDevMode()) { + throw new Error(`[appExpertiseLevel] got undefined expertise-level value`); + } + return; + } + if (lvl !== this.allowedValue) { + this.allowedValue = lvl as ExpertiseLevelNumber; + this.update(); + } + } + + private update() { + const current = ExpertiseLevelNumber[this.expertiseService.currentLevel]; + let hide = current < this.allowedValue; + + // if there's an overwrite function defined make sue to check that. + if (hide && !!this._levelOverwriteFn) { + hide = !this._levelOverwriteFn(current, this._data!); + if (!hide) { + console.log("overwritten", current, this._data); + } + } + + if (hide) { + if (!!this.view) { + this.view.destroy(); + this.viewContainer.clear(); + this.view = null; + } + return + } + + if (!!this.view) { + this.view.markForCheck(); + return; + } + + this.view = this.viewContainer.createEmbeddedView(this.templateRef); + this.view.detectChanges(); + } + + constructor( + private expertiseService: ExpertiseService, + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef + ) { } + + ngOnInit() { + this.subscription = this.expertiseService.change.subscribe(() => this.update()) + } + + ngOnDestroy() { + this.viewContainer.clear(); + this.subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/shared/expertise/expertise-switch.html b/desktop/angular/src/app/shared/expertise/expertise-switch.html new file mode 100644 index 00000000..8ad8eca7 --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/expertise-switch.html @@ -0,0 +1,16 @@ + + + + Simple Interface + + + + Advanced Interface + + + + + Developer Interface + + + diff --git a/desktop/angular/src/app/shared/expertise/expertise-switch.scss b/desktop/angular/src/app/shared/expertise/expertise-switch.scss new file mode 100644 index 00000000..b795503d --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/expertise-switch.scss @@ -0,0 +1,12 @@ +:host { + display: flex; + @apply pl-2; + user-select: none; + flex-direction: row; + align-items: center; + justify-content: center; +} + +sfng-tipup { + margin-right: 0.5rem; +} diff --git a/desktop/angular/src/app/shared/expertise/expertise-switch.ts b/desktop/angular/src/app/shared/expertise/expertise-switch.ts new file mode 100644 index 00000000..1b822927 --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/expertise-switch.ts @@ -0,0 +1,38 @@ +import { Component, ElementRef } from '@angular/core'; +import { ExpertiseLevel } from '@safing/portmaster-api'; +import { ExpertiseService } from './expertise.service'; + +@Component({ + selector: 'app-expertise', + templateUrl: './expertise-switch.html', + styleUrls: ['./expertise-switch.scss'] +}) +export class ExpertiseComponent { + /** @private provide the expertise-level enums to the template */ + readonly expertiseLevels = ExpertiseLevel; + + currentLevel = this.expertiseService.change; + + /** + * @private + * Getter to access the expertise level as saved in the database + */ + get savedLevel() { + return this.expertiseService.savedLevel; + } + + constructor( + private expertiseService: ExpertiseService, + public host: ElementRef, + ) { } + + /** + * @private + * Configures a new expertise level + * + * @param lvl The new expertise level to use + */ + selectLevel(lvl: ExpertiseLevel) { + this.expertiseService.setLevel(lvl); + } +} diff --git a/desktop/angular/src/app/shared/expertise/expertise.module.ts b/desktop/angular/src/app/shared/expertise/expertise.module.ts new file mode 100644 index 00000000..7bf6a7fa --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/expertise.module.ts @@ -0,0 +1,24 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { SfngSelectModule, SfngTipUpModule } from "@safing/ui"; +import { ExpertiseDirective } from "./expertise-directive"; +import { ExpertiseComponent } from "./expertise-switch"; + +@NgModule({ + imports: [ + SfngSelectModule, + CommonModule, + SfngTipUpModule, + FormsModule, + ], + declarations: [ + ExpertiseComponent, + ExpertiseDirective, + ], + exports: [ + ExpertiseComponent, + ExpertiseDirective, + ] +}) +export class ExpertiseModule { } diff --git a/desktop/angular/src/app/shared/expertise/expertise.service.ts b/desktop/angular/src/app/shared/expertise/expertise.service.ts new file mode 100644 index 00000000..5b5d7a20 --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/expertise.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { ConfigService, ExpertiseLevel, StringSetting } from '@safing/portmaster-api'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, map, repeat, share } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class ExpertiseService { + /** If the user overwrites the expertise level on a per-page setting we track that here */ + private _localOverwrite: ExpertiseLevel | null = null; + private _currentLevel: ExpertiseLevel = ExpertiseLevel.User; + + /** Watches the expertise level as saved in the configuration */ + private _savedLevel$ = this.configService.watch('core/expertiseLevel') + .pipe( + repeat({ delay: 2000 }), + map(upd => { + return upd as ExpertiseLevel; + }), + distinctUntilChanged(), + share(), + ); + + private level$ = new BehaviorSubject(ExpertiseLevel.User); + + get currentLevel() { + return this._localOverwrite === null + ? this._currentLevel + : this._localOverwrite; + } + + get savedLevel() { + return this._currentLevel; + } + + get change(): Observable { + return this.level$.asObservable(); + } + + constructor(private configService: ConfigService) { + this._savedLevel$ + .subscribe(lvl => { + this._currentLevel = lvl; + if (this._localOverwrite === null) { + this.level$.next(lvl); + } + }); + } + + setLevel(lvl: ExpertiseLevel | null) { + if (lvl === this._currentLevel) { + lvl = null; + } + + this._localOverwrite = lvl; + if (!!lvl) { + this.level$.next(lvl); + } else { + this.level$.next(this._currentLevel!); + } + } +} diff --git a/desktop/angular/src/app/shared/expertise/index.ts b/desktop/angular/src/app/shared/expertise/index.ts new file mode 100644 index 00000000..6c41ae61 --- /dev/null +++ b/desktop/angular/src/app/shared/expertise/index.ts @@ -0,0 +1,3 @@ +export * from './expertise-directive'; +export * from './expertise-switch'; +export * from './expertise.service'; diff --git a/desktop/angular/src/app/shared/external-link.directive.ts b/desktop/angular/src/app/shared/external-link.directive.ts new file mode 100644 index 00000000..47a16c28 --- /dev/null +++ b/desktop/angular/src/app/shared/external-link.directive.ts @@ -0,0 +1,53 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + Directive, + HostBinding, HostListener, Inject, + Input, OnChanges, PLATFORM_ID, inject +} from '@angular/core'; +import { INTEGRATION_SERVICE } from '../integration'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'a[href]' +}) +export class ExternalLinkDirective implements OnChanges { + private readonly integration = inject(INTEGRATION_SERVICE); + + @HostBinding('attr.rel') + relAttr = ''; + + @HostBinding('attr.target') + targetAttr = ''; + + @HostBinding('attr.href') + hrefAttr = ''; + + @Input() + href: string = ''; + + constructor(@Inject(PLATFORM_ID) private platformId: string) { } + + @HostListener('click', ['$event']) + onClick(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + this.integration.openExternal(this.href); + } + + ngOnChanges() { + this.hrefAttr = this.href; + + if (this.isLinkExternal()) { + this.relAttr = 'noopener'; + this.targetAttr = '_blank'; + } + } + + private isLinkExternal() { + return ( + isPlatformBrowser(this.platformId) && + !this.href.includes(location.hostname) + ); + } +} diff --git a/desktop/angular/src/app/shared/feature-scout/feature-scout.html b/desktop/angular/src/app/shared/feature-scout/feature-scout.html new file mode 100644 index 00000000..f0ee7af7 --- /dev/null +++ b/desktop/angular/src/app/shared/feature-scout/feature-scout.html @@ -0,0 +1,106 @@ + +
+ +
+ + + + + + + + + + + + + + + + +
+ +
+
+ + + + SPN is connecting...
+ Fail-safe blocking enabled +
+ + SPN failed to connect
+ Fail-safe blocking enabled +
+ + SPN is connecting...
+ Fail-safe blocking enabled +
+ + + + + {{ spnStatus?.HomeHubName }} + + in + + + {{ spnStatus?.ConnectedCountry?.Name }} + +
+ +
+
+ + + +
+
+ + + SPN Home (Entry) Node +
    +
  • Connected to {{ spnStatus?.ConnectedIP }}
  • +
  • Uplink is always encrypted
  • +
  • Built with transport/decoy {{ spnStatus?.ConnectedTransport }}
  • +
+
diff --git a/desktop/angular/src/app/shared/feature-scout/feature-scout.scss b/desktop/angular/src/app/shared/feature-scout/feature-scout.scss new file mode 100644 index 00000000..5ae271f6 --- /dev/null +++ b/desktop/angular/src/app/shared/feature-scout/feature-scout.scss @@ -0,0 +1,15 @@ +.feature-icon { + @apply text-primary text-opacity-80; + + &.feature-icon-off { + opacity: 0.25; + } +} + +.status-info { + @apply text-primary text-opacity-80 text-xxs text-center; + + &:hover { + cursor: default; + } +} diff --git a/desktop/angular/src/app/shared/feature-scout/feature-scout.ts b/desktop/angular/src/app/shared/feature-scout/feature-scout.ts new file mode 100644 index 00000000..596edf14 --- /dev/null +++ b/desktop/angular/src/app/shared/feature-scout/feature-scout.ts @@ -0,0 +1,98 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BoolSetting, ConfigService, FeatureID, Netquery, SPNService, SPNStatus, UserProfile } from "@safing/portmaster-api"; +import { catchError, of } from "rxjs"; +import { fadeInAnimation, fadeOutAnimation } from "../animations"; +import { CountryFlagModule } from 'src/app/shared/country-flag'; + +@Component({ + selector: 'app-feature-scout', + templateUrl: './feature-scout.html', + styleUrls: [ + './feature-scout.scss' + ], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeOutAnimation, + ] +}) +export class FeatureScoutComponent implements OnInit { + private destroyRef = inject(DestroyRef); + + /** The current SPN user profile */ + profile: UserProfile | null = null; + + /** Whether or not the SPN is currently enabled */ + spnEnabled = false; + + /** The current status of the SPN module */ + spnStatus: SPNStatus | null = null; + + /** Whether or not the Network History is currently enabled */ + historyEnabled = false; + + /** Returns whether or not the current package has the SPN feature */ + get packageHasSPN() { + return this.profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) + } + + /** Returns whether or not the current package has the Network History feature */ + get packageHasHistory() { + return this.profile?.current_plan?.feature_ids?.includes(FeatureID.History) + } + + constructor( + private configService: ConfigService, + private spnService: SPNService, + private cdr: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.spnService + .profile$ + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError(() => of(null)) + ) + .subscribe(profile => { + this.profile = profile || null; + + this.cdr.markForCheck(); + }); + + this.spnService.status$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(status => { + this.spnStatus = status; + + this.cdr.markForCheck(); + }) + + this.configService.watch("spn/enable") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(value => { + this.spnEnabled = value; + + this.cdr.markForCheck(); + }); + + this.configService.watch("history/enable") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(value => { + this.historyEnabled = value; + + this.cdr.markForCheck(); + }); + } + + setSPNEnabled(v: boolean) { + this.configService.save(`spn/enable`, v) + .subscribe(); + } + + setHistoryEnabled(v: boolean) { + this.configService.save(`history/enable`, v) + .subscribe(); + } +} diff --git a/desktop/angular/src/app/shared/feature-scout/index.ts b/desktop/angular/src/app/shared/feature-scout/index.ts new file mode 100644 index 00000000..6fc7f610 --- /dev/null +++ b/desktop/angular/src/app/shared/feature-scout/index.ts @@ -0,0 +1 @@ +export * from './feature-scout'; diff --git a/desktop/angular/src/app/shared/focus/focus.directive.ts b/desktop/angular/src/app/shared/focus/focus.directive.ts new file mode 100644 index 00000000..79b83f40 --- /dev/null +++ b/desktop/angular/src/app/shared/focus/focus.directive.ts @@ -0,0 +1,32 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { Directive, ElementRef, Input, OnInit } from "@angular/core"; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[autoFocus]', +}) +export class AutoFocusDirective implements OnInit { + private _focus = true; + private _afterInit = false; + + @Input('autoFocus') + set focus(v: any) { + this._focus = coerceBooleanProperty(v) !== false; + + if (this._afterInit && this.elementRef) { + this.elementRef.nativeElement.focus() + } + } + + constructor(private elementRef: ElementRef) { } + + ngOnInit(): void { + setTimeout(() => { + if (this._focus) { + this.elementRef.nativeElement.focus(); + } + }, 100) + + this._afterInit = true; + } +} diff --git a/desktop/angular/src/app/shared/focus/focus.module.ts b/desktop/angular/src/app/shared/focus/focus.module.ts new file mode 100644 index 00000000..29593994 --- /dev/null +++ b/desktop/angular/src/app/shared/focus/focus.module.ts @@ -0,0 +1,16 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { AutoFocusDirective } from "./focus.directive"; + +@NgModule({ + imports: [ + CommonModule, + ], + declarations: [ + AutoFocusDirective, + ], + exports: [ + AutoFocusDirective, + ] +}) +export class SfngFocusModule { } diff --git a/desktop/angular/src/app/shared/focus/index.ts b/desktop/angular/src/app/shared/focus/index.ts new file mode 100644 index 00000000..f7f9cff0 --- /dev/null +++ b/desktop/angular/src/app/shared/focus/index.ts @@ -0,0 +1,2 @@ +export { AutoFocusDirective } from './focus.directive'; +export * from './focus.module'; diff --git a/desktop/angular/src/app/shared/fuzzySearch/fuse.service.ts b/desktop/angular/src/app/shared/fuzzySearch/fuse.service.ts new file mode 100644 index 00000000..d7f3e192 --- /dev/null +++ b/desktop/angular/src/app/shared/fuzzySearch/fuse.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@angular/core'; +import { deepClone } from '@safing/portmaster-api'; +import Fuse from 'fuse.js'; + +export type FuseResult = Fuse.FuseResult; + +export interface FuseSearchOpts extends Fuse.IFuseOptions { + minSearchTermLength?: number; + maximumScore?: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class FuzzySearchService { + + readonly defaultOptions: FuseSearchOpts = { + minMatchCharLength: 2, + includeMatches: true, + includeScore: true, + minSearchTermLength: 3, + }; + + searchList(list: Array, searchTerms: string, options: FuseSearchOpts & { disableHighlight?: boolean } = {}): Array> { + const opts: FuseSearchOpts = { + ...this.defaultOptions, + ...options, + } + + let result: FuseResult[] = []; + + + if (searchTerms && searchTerms.length >= (opts.minSearchTermLength || 0)) { + let fuse = new Fuse(list, opts); + result = fuse.search(searchTerms); + + } else { + result = list.map((item, index) => ({ + item: item, + refIndex: index, + score: 0, + })) + } + + if (!!options.disableHighlight) { + return result; + } + + return this.handleHighlight(result, options); + } + + private handleHighlight(result: FuseResult[], options: FuseSearchOpts): FuseResult[] { + return result.map(matchObject => { + matchObject.item = deepClone(matchObject.item); + + if (!matchObject.matches) { + return matchObject; + } + + for (let match of matchObject.matches!) { + const indices = match.indices; + + let highlightOffset: number = 0; + + for (let indice of indices) { + let initialValue = getFromMatch(matchObject, match); + + const startOffset = indice[0] + highlightOffset; + const endOffset = indice[1] + highlightOffset + 1; + + if (endOffset - startOffset < 4) { + continue + } + + let highlightedTerm = initialValue.substring(startOffset, endOffset); + let newValue = initialValue.substring(0, startOffset) + '' + highlightedTerm + '' + initialValue.substring(endOffset); + + highlightOffset += ''.length; + + setOnMatch(matchObject, match, newValue); + } + } + + return matchObject; + }); + } +} + +function getFromMatch(result: Fuse.FuseResult, match: Fuse.FuseResultMatch): string { + if (match.refIndex === undefined) { + return (result.item as any)[match.key!]; + } + return (result.item as any)[match.key!][match.refIndex]; +} + +function setOnMatch(result: Fuse.FuseResult, match: Fuse.FuseResultMatch, value: string) { + if (match.refIndex === undefined) { + (result.item as any)[match.key!] = value; + return; + } + + (result.item as any)[match.key!][match.refIndex] = value; +} diff --git a/desktop/angular/src/app/shared/fuzzySearch/index.ts b/desktop/angular/src/app/shared/fuzzySearch/index.ts new file mode 100644 index 00000000..d1194321 --- /dev/null +++ b/desktop/angular/src/app/shared/fuzzySearch/index.ts @@ -0,0 +1,4 @@ +import Fuse from 'fuse.js'; + +export { FuseSearchOpts, FuzzySearchService } from './fuse.service'; +export { FuzzySearchPipe } from './search-pipe'; diff --git a/desktop/angular/src/app/shared/fuzzySearch/search-pipe.ts b/desktop/angular/src/app/shared/fuzzySearch/search-pipe.ts new file mode 100644 index 00000000..4f6d7b1b --- /dev/null +++ b/desktop/angular/src/app/shared/fuzzySearch/search-pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { FuseResult, FuseSearchOpts, FuzzySearchService } from './fuse.service'; + + +@Pipe({ + name: 'fuzzySearch', +}) +export class FuzzySearchPipe implements PipeTransform { + constructor( + private FusejsService: FuzzySearchService + ) { } + + transform(elements: Array, + searchTerms: string, + options: FuseSearchOpts = {}): Array> { + + return this.FusejsService.searchList(elements, searchTerms, options); + } +} diff --git a/desktop/angular/src/app/shared/loading/index.ts b/desktop/angular/src/app/shared/loading/index.ts new file mode 100644 index 00000000..68c5f495 --- /dev/null +++ b/desktop/angular/src/app/shared/loading/index.ts @@ -0,0 +1 @@ +export { LoadingComponent } from './loading'; diff --git a/desktop/angular/src/app/shared/loading/loading.html b/desktop/angular/src/app/shared/loading/loading.html new file mode 100644 index 00000000..bfa7d9b6 --- /dev/null +++ b/desktop/angular/src/app/shared/loading/loading.html @@ -0,0 +1,3 @@ + + + diff --git a/desktop/angular/src/app/shared/loading/loading.scss b/desktop/angular/src/app/shared/loading/loading.scss new file mode 100644 index 00000000..fccdce9d --- /dev/null +++ b/desktop/angular/src/app/shared/loading/loading.scss @@ -0,0 +1,52 @@ +:host { + --internal-dot-size : var(--dot-size, 5px); + --internal-animation-speed: var(--animation-speed, 1.3s); + + display : flex; + position : relative; + justify-content: space-evenly; + align-items : flex-end; + width : var(--animation-width, calc(var(--internal-dot-size) * 5)); + + height: calc(var(--internal-dot-size) * 3); + + &.animate { + .dot { + display : block; + flex-shrink: 0; + flex-grow : 0; + width : var(--internal-dot-size); + height : var(--internal-dot-size); + + @apply shadow-inner-xs; + @apply rounded-full; + @apply bg-buttons-icon; + + animation: wave var(--internal-animation-speed) linear infinite; + + &:nth-child(2) { + animation-delay: -1.1s; + } + + &:nth-child(3) { + animation-delay: -0.9s; + } + } + } + +} + +@keyframes wave { + + 0%, + 60%, + 100% { + transform: initial; + @apply bg-buttons-light; + } + + 90% { + transform : translateY(var(--loading-height, -9px)); + background-color: white; + } +} diff --git a/desktop/angular/src/app/shared/loading/loading.ts b/desktop/angular/src/app/shared/loading/loading.ts new file mode 100644 index 00000000..fb7f049d --- /dev/null +++ b/desktop/angular/src/app/shared/loading/loading.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding } from '@angular/core'; + +@Component({ + selector: 'app-loading', + templateUrl: './loading.html', + styleUrls: ['./loading.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoadingComponent { + @HostBinding('class.animate') + _animate = true; + + constructor(private changeDetectorRef: ChangeDetectorRef) { } +} diff --git a/desktop/angular/src/app/shared/menu/index.ts b/desktop/angular/src/app/shared/menu/index.ts new file mode 100644 index 00000000..bb5dcd95 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/index.ts @@ -0,0 +1,2 @@ +export { MenuComponent, MenuTriggerComponent, MenuItemComponent, MenuGroupComponent } from './menu'; +export * from './menu.module'; diff --git a/desktop/angular/src/app/shared/menu/menu-group.scss b/desktop/angular/src/app/shared/menu/menu-group.scss new file mode 100644 index 00000000..c2cd7063 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu-group.scss @@ -0,0 +1,13 @@ +:host { + display: block; + width: 100%; + + @apply p-1; + @apply px-4; + @apply text-secondary; + + display: block; + text-transform: uppercase; + font-size: 0.7rem; + opacity: .7; +} diff --git a/desktop/angular/src/app/shared/menu/menu-item.scss b/desktop/angular/src/app/shared/menu/menu-item.scss new file mode 100644 index 00000000..fdba9c32 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu-item.scss @@ -0,0 +1,17 @@ +:host { + @apply block w-full; + + cursor: pointer; + @apply p-2; + @apply px-4 text-primary text-xxs; + font-weight: 500; + + &:hover { + @apply bg-gray-300; + } + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } +} diff --git a/desktop/angular/src/app/shared/menu/menu-trigger.html b/desktop/angular/src/app/shared/menu/menu-trigger.html new file mode 100644 index 00000000..02642109 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu-trigger.html @@ -0,0 +1,14 @@ +
+ +
diff --git a/desktop/angular/src/app/shared/menu/menu-trigger.scss b/desktop/angular/src/app/shared/menu/menu-trigger.scss new file mode 100644 index 00000000..77cc16b0 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu-trigger.scss @@ -0,0 +1,41 @@ +:host { + user-select: none; + margin-right: .5rem; + display: block; + @apply rounded-t-sm; +} + +div { + cursor: pointer; + display: flex; + @apply rounded-t; + flex-grow: 0; + transition: all .1s ease-in-out; + justify-content: center; + align-items: center; + @apply py-1; + @apply px-3; +} + +.dropdown { + margin-left: 1px; + height: auto; + padding: 0; + margin: 0; + + svg { + opacity: 0.7; + fill: var(--text-primary); + width: 0.51rem; + transition: all cubic-bezier(0.175, 0.885, 0.32, 1.275) .2s; + + transform: rotate(90deg); + position: relative; + top: 3px; + } +} + +:host.active { + @apply bg-gray-400; + color: white !important; +} diff --git a/desktop/angular/src/app/shared/menu/menu.html b/desktop/angular/src/app/shared/menu/menu.html new file mode 100644 index 00000000..d33da3d7 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu.html @@ -0,0 +1,6 @@ + +
+ +
+
diff --git a/desktop/angular/src/app/shared/menu/menu.module.ts b/desktop/angular/src/app/shared/menu/menu.module.ts new file mode 100644 index 00000000..4f97a6c0 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu.module.ts @@ -0,0 +1,26 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { SfngDropDownModule } from "@safing/ui"; +import { MenuComponent, MenuGroupComponent, MenuItemComponent, MenuTriggerComponent } from "./menu"; + +@NgModule({ + imports: [ + SfngDropDownModule, + CommonModule, + OverlayModule, + ], + declarations: [ + MenuComponent, + MenuGroupComponent, + MenuTriggerComponent, + MenuItemComponent, + ], + exports: [ + MenuComponent, + MenuGroupComponent, + MenuTriggerComponent, + MenuItemComponent, + ], +}) +export class SfngMenuModule { } diff --git a/desktop/angular/src/app/shared/menu/menu.ts b/desktop/angular/src/app/shared/menu/menu.ts new file mode 100644 index 00000000..f5467921 --- /dev/null +++ b/desktop/angular/src/app/shared/menu/menu.ts @@ -0,0 +1,111 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { CdkOverlayOrigin } from '@angular/cdk/overlay'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, EventEmitter, HostBinding, HostListener, Input, Output, QueryList, ViewChild } from '@angular/core'; +import { SfngDropdownComponent } from '@safing/ui'; + +@Component({ + selector: 'app-menu-trigger', + templateUrl: './menu-trigger.html', + styleUrls: ['./menu-trigger.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuTriggerComponent { + @ViewChild(CdkOverlayOrigin, { static: true }) + origin!: CdkOverlayOrigin; + + @Input() + menu: MenuComponent | null = null; + + @Input() + set useContent(v: any) { + this._useContent = coerceBooleanProperty(v); + } + get useContent() { return this._useContent; } + private _useContent: boolean = false; + + @HostBinding('class.active') + get isOpen() { + if (!this.menu) { + return false; + } + + return this.menu.dropdown.isOpen; + } + + constructor( + public changeDetectorRef: ChangeDetectorRef, + ) { } + + toggle(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.menu?.dropdown.toggle(this.origin) + } +} + +@Component({ + selector: 'app-menu-item', + template: '', + styleUrls: ['./menu-item.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuItemComponent { + @Input() + @HostBinding('class.disabled') + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v); + } + get disabled() { return this._disabled; } + private _disabled: boolean = false; + + @HostListener('click', ['$event']) + closeMenu(event: MouseEvent) { + if (this.disabled) { + return; + } + this.activate.next(event); + this.menu.dropdown.close(); + } + + /** + * activate fires when the menu item is clicked. + * Use activate rather than (click)="" if you want + * [disabled] to be considered. + */ + @Output() + activate = new EventEmitter(); + + constructor(private menu: MenuComponent) { } +} + +@Component({ + selector: 'app-menu-group', + template: '', + styleUrls: ['./menu-group.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuGroupComponent { } + +@Component({ + selector: 'app-menu', + exportAs: 'appMenu', + templateUrl: './menu.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuComponent { + @ContentChildren(MenuItemComponent) + items: QueryList | null = null; + + @ViewChild(SfngDropdownComponent, { static: true }) + dropdown!: SfngDropdownComponent; + + @Input() + offsetY?: string | number; + + @Input() + offsetX?: string | number; + + @Input() + overlayClass?: string; +} diff --git a/desktop/angular/src/app/shared/multi-switch/index.ts b/desktop/angular/src/app/shared/multi-switch/index.ts new file mode 100644 index 00000000..6009e482 --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/index.ts @@ -0,0 +1,3 @@ +export { MultiSwitchComponent } from './multi-switch'; +export { SwitchItemComponent } from './switch-item'; +export * from './multi-switch.module'; diff --git a/desktop/angular/src/app/shared/multi-switch/multi-switch.html b/desktop/angular/src/app/shared/multi-switch/multi-switch.html new file mode 100644 index 00000000..6af6d004 --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/multi-switch.html @@ -0,0 +1,5 @@ +
+ + +
+ diff --git a/desktop/angular/src/app/shared/multi-switch/multi-switch.module.ts b/desktop/angular/src/app/shared/multi-switch/multi-switch.module.ts new file mode 100644 index 00000000..a3404d52 --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/multi-switch.module.ts @@ -0,0 +1,26 @@ +import { DragDropModule } from "@angular/cdk/drag-drop"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { SfngTipUpModule, SfngTooltipModule } from "@safing/ui"; +import { MultiSwitchComponent } from "./multi-switch"; +import { SwitchItemComponent } from "./switch-item"; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + SfngTooltipModule, + SfngTipUpModule, + DragDropModule, + ], + declarations: [ + MultiSwitchComponent, + SwitchItemComponent, + ], + exports: [ + MultiSwitchComponent, + SwitchItemComponent, + ], +}) +export class SfngMultiSwitchModule { } diff --git a/desktop/angular/src/app/shared/multi-switch/multi-switch.scss b/desktop/angular/src/app/shared/multi-switch/multi-switch.scss new file mode 100644 index 00000000..6b96e2f3 --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/multi-switch.scss @@ -0,0 +1,46 @@ +.buttons { + display: flex; + align-items: flex-end; + position: relative; + height: 3rem; + flex-grow: 0; + width: fit-content; + + fa-icon[icon*="question-circle"] { + height: 100%; + display: flex; + align-items: center; + margin-left: 1rem; + } +} + +.marker { + display: block; + height: 16px; + width: 16px; + position: absolute; + bottom: -8px; + cursor: grab; + transition: all .5s cubic-bezier(0.175, 0.885, 0.32, 1.075); + @apply rounded-full; +} + +:host { + flex-grow: 0; + width: fit-content; + display: block; + outline: none; + user-select: none; + + &.disabled { + .marker { + cursor: unset; + } + } + + &.grabbing { + .marker { + cursor: grabbing; + } + } +} diff --git a/desktop/angular/src/app/shared/multi-switch/multi-switch.ts b/desktop/angular/src/app/shared/multi-switch/multi-switch.ts new file mode 100644 index 00000000..2726427c --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/multi-switch.ts @@ -0,0 +1,370 @@ +import { ListKeyManager } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DOCUMENT } from '@angular/common'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, EventEmitter, forwardRef, HostBinding, HostListener, Inject, Input, NgZone, OnDestroy, Output, QueryList, Renderer2, ViewChild } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { animationFrameScheduler, fromEvent, Subscription } from 'rxjs'; +import { map, startWith, subscribeOn, take, takeUntil } from 'rxjs/operators'; +import { SwitchItemComponent } from './switch-item'; + +@Component({ + selector: 'app-multi-switch', + templateUrl: './multi-switch.html', + styleUrls: ['./multi-switch.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MultiSwitchComponent), + multi: true, + } + ] +}) +export class MultiSwitchComponent implements OnDestroy, AfterViewInit, ControlValueAccessor { + /** Subscription to all button-select changes */ + private sub = Subscription.EMPTY; + + /** Holds the current x-translation offset for the marker */ + private markerOffset: number = 0; + + /** Keymanager used for keyboard navigation support */ + private keyManager: ListKeyManager> | null = null; + + /** Subscription to the key manager */ + private keyManagerSub = Subscription.EMPTY; + + @Input() + tipUpKey: string = ''; + + /** All buttons projected into the multi-switch */ + @ContentChildren(SwitchItemComponent) + buttons: QueryList> | null = null; + + /** Emits whenever the selected button changes. */ + @Output() + changed = new EventEmitter(); + + /** Reference to the marker inside our view container */ + @ViewChild('marker', { read: ElementRef, static: true }) + marker: ElementRef | null = null; + + @HostListener('blur') + onBlur() { + this._onTouch(); + } + + @HostBinding('attr.tabindex') + readonly tabindex = 0; + + @HostListener('keyup', ['$event']) + onKeyUp(event: KeyboardEvent) { + if (this.disabled) { + return; + } + this.keyManager!.onKeydown(event); + } + + /** Whether or not the switch button component is disabled */ + @Input() + @HostBinding('class.disabled') + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v); + + // Update all buttons states as well. + if (!!this.buttons) { + this.buttons.forEach(btn => btn.disabled = this.disabled); + } + } + get disabled() { return this._disabled; } + private _disabled = false; + + @HostBinding('class.grabbing') + isGrabbing = false; + + /** External write tracks calls to writeValue so we don't end up re-emitting the values. */ + private externalWrite = false; + + /** Which button is currently active (and holds the marker) */ + activeButton: T | null = null; + + constructor( + public host: ElementRef, + private changeDetectorRef: ChangeDetectorRef, + private renderer: Renderer2, + private ngZone: NgZone, + @Inject(DOCUMENT) private document: Document, + ) { } + + /** Registeres the change callback. Required for ControlValueAccessor */ + registerOnChange(fn: (v: T) => void) { + this._onChange = fn; + } + private _onChange: (value: T) => void = () => { } + + /** Registers the touch callback. Required for ControlValueAccessor */ + registerOnTouched(fn: () => void) { + this._onTouch = fn; + } + private _onTouch: () => void = () => { }; + + /** Disable or enable the button. Required for ControlValueAccessor */ + setDisabledState(disabled: boolean) { + this.disabled = disabled; + } + + /** Writes a new value for the multi-line switch */ + writeValue(value: T) { + this.activeButton = value; + if (!!this.buttons) { + // Set externalWrite to true while we iterate the buttons + // and eventually call `setActiveItem` so we don't re-emit + // the active item once the keyManager publishes the change + // to use. + // This workaround is required as we need to inform the + // keyManager about the new active item. Otherwise it would + // work with a stale internal state the next time the user + // uses the keyboard. + this.externalWrite = true; + this.buttons.forEach(btn => { + if (btn.id === value) { + this.keyManager!.setActiveItem(btn); + this.repositionMarker(btn); + } + }) + this.externalWrite = false; + } + } + + ngAfterViewInit() { + if (!this.buttons) { + return; + } + + this.keyManager = new ListKeyManager(this.buttons) + .withHorizontalOrientation('ltr') + .withTypeAhead() + .withWrap(); + + this.keyManagerSub = this.keyManager.change + .subscribe(activeIndex => { + const active = Array.from(this.buttons!)[activeIndex]; + this.selectButton(active, !this.externalWrite); + }); + + // Subscribe to all (clicked) and (selectedChange) events of + // all buttons projected into our content. + this.buttons.changes + .pipe(startWith(null)) + .subscribe(() => { + this.sub.unsubscribe(); + this.sub = new Subscription(); + + this.buttons!.forEach(btn => { + btn.disabled = this.disabled; + this.sub.add( + btn.clicked.subscribe((e: MouseEvent) => { + this.keyManager!.setActiveItem(btn); + }) + ); + }); + + // wait until the zone and change-detection stabilizes and + // reposition the marker afterwards. Doing it right now will + // likely position it wrongly since the DOM has not yet been + // fully updated. + this.ngZone.onStable.pipe(take(1)) + .subscribe(() => this.repositionMarker()) + }); + + this.buttons.forEach(btn => { + if (this.activeButton === btn.id) { + btn.selected = true; + } + }) + + this.repositionMarker(); + } + + ngOnDestroy() { + this.sub.unsubscribe(); + this.keyManagerSub.unsubscribe(); + } + + /** Selects a new button and deselects all others. */ + private selectButton(btn: SwitchItemComponent, emit = true) { + if (this.disabled) { + return; + } + + this.activeButton = btn.id; + + if (emit) { + this.changed.next(btn.id!); + this._onChange(btn.id!); + } + + this.repositionMarker(btn); + } + + /** @private View-callback for (mousedown) to start dragging the marker. */ + dragStarted(event: MouseEvent) { + if (this.disabled) { + return; + } + + this.isGrabbing = true; + this.renderer.addClass(this.document.getElementsByTagName("body")[0], 'document-grabbing'); + + const mousemove$ = fromEvent(this.document, 'mousemove'); + const hostRect = this.host.nativeElement.getBoundingClientRect(); + const start = this.markerOffset; + const markerWidth = this.marker!.nativeElement.getBoundingClientRect().width; + + // we don't want angular to run change detection all the time we move a pixel + // so detach the change-detector for now. + this.changeDetectorRef.detach(); + + mousemove$ + .pipe( + map(move => { + move.preventDefault(); + return move.clientX - event.clientX; + }), + takeUntil(fromEvent(document, 'mouseup')), + subscribeOn(animationFrameScheduler) + ) + .subscribe({ + next: diff => { + // clip the new offset inside our host-view. + let offset = start + diff; + if (offset < 0) { + offset = 0; + } else if (offset > hostRect.width) { + offset = hostRect.width; + } + + // center the marker at the mouse position. + offset -= Math.round(markerWidth / 2); + + this.markerOffset = offset; + this.updatePosition(offset); + + let foundTarget = false; + let target = this.findTargetButton(offset); + + if (!!target) { + this.marker!.nativeElement.style.backgroundColor = target.borderColorActive; + + this.buttons!.forEach(btn => { + if (!foundTarget && btn.group === target!.group) { + this.renderer.addClass(btn.elementRef.nativeElement, 'selected'); + btn.elementRef.nativeElement.style.borderColor = btn.borderColorActive; + } else { + this.renderer.removeClass(btn.elementRef.nativeElement, 'selected'); + btn.elementRef.nativeElement.style.borderColor = btn.borderColorInactive; + } + + if (target === btn) { + foundTarget = true; + } + }); + } + }, + complete: () => { + this.changeDetectorRef.reattach(); + this.markerDropped(); + + // make sure we don't keep the selected class on buttons that + // are not selected anymore. + this.buttons!.forEach(btn => { + if (!btn.selected) { + this.renderer.removeClass(btn.elementRef.nativeElement, 'selected'); + btn.elementRef.nativeElement.style.borderColor = btn.borderColorInactive; + } + }); + + this.isGrabbing = false; + this.renderer.removeClass(this.document.getElementsByTagName("body")[0], 'document-grabbing'); + } + }); + } + + /** Update the markers position by applying a translate3d */ + private updatePosition(x: number) { + this.marker!.nativeElement.style.transform = `translate3d(${x}px, 0px, 0px)`; + } + + /** Find the button item that is below x */ + private findTargetButton(x: number, cb?: (item: SwitchItemComponent, target: boolean) => void): SwitchItemComponent | null { + const host = this.host.nativeElement.getBoundingClientRect(); + let newButton: SwitchItemComponent | null = null; + this.buttons?.forEach(btn => { + const btnRect = btn.elementRef.nativeElement.getBoundingClientRect(); + const min = btnRect.x - host.x; + const max = min + btnRect.width; + + if (x >= min && x <= max) { + newButton = btn; + + if (!!cb) { + cb(btn, true); + } + } else if (!!cb) { + cb(btn, false); + } + }); + + return newButton; + } + + /** Calculates which button should be activated based on the drop-position of the marker */ + private markerDropped() { + let newButton = this.findTargetButton(this.markerOffset); + + if (!newButton) { + newButton = Array.from(this.buttons!)[0]; + } + + if (!!newButton) { + this.keyManager!.setActiveItem(newButton); + } + } + + /** + * Calculates the new position required to center the + * marker at the currently selected button. + * If `selected` is unset the last button with selected == true is + * used. + * + * @param selected The switch item button to select (optional). + */ + private repositionMarker(selected: SwitchItemComponent | null = null) { + // If there's no selected button given search for the last one that + // matches selected === true. + if (selected === null) { + this.buttons?.forEach(btn => { + if (btn.selected) { + selected = btn; + } + }); + } + + // There's not button selected so we move the marker back to the + // start. + if (selected === null) { + this.markerOffset = 0; + this.updatePosition(0); + return; + } + + // Calculate and reposition the marker. + const offsetLeft = selected!.elementRef.nativeElement.offsetLeft; + const clientWidth = selected!.elementRef.nativeElement.clientWidth; + + this.markerOffset = Math.round(offsetLeft - 8 + clientWidth / 2); + this.marker!.nativeElement.style.backgroundColor = selected.borderColorActive; + + this.updatePosition(this.markerOffset); + this.changeDetectorRef.markForCheck(); + } +} diff --git a/desktop/angular/src/app/shared/multi-switch/switch-item.scss b/desktop/angular/src/app/shared/multi-switch/switch-item.scss new file mode 100644 index 00000000..da737c13 --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/switch-item.scss @@ -0,0 +1,35 @@ +:host { + display : flex; + align-items : center; + justify-content: center; + width : 6rem; + height : 2.7rem; + position : relative; + bottom : 0; + transition : all .3s cubic-bezier(0.075, 0.82, 0.165, 1); + + @apply bg-buttons-dark; + + @apply border-b-2; + + &.selected { + @apply bg-buttons-light; + height: 3rem; + } + + &:not(.disabled) { + cursor: pointer; + + &:hover { + @apply bg-buttons-light; + } + } + + &:first-of-type { + @apply rounded-tl; + } + + &:last-of-type { + @apply rounded-tr; + } +} diff --git a/desktop/angular/src/app/shared/multi-switch/switch-item.ts b/desktop/angular/src/app/shared/multi-switch/switch-item.ts new file mode 100644 index 00000000..f70f3a2f --- /dev/null +++ b/desktop/angular/src/app/shared/multi-switch/switch-item.ts @@ -0,0 +1,80 @@ +import { Component, ChangeDetectionStrategy, Input, isDevMode, OnInit, HostBinding, Output, EventEmitter, HostListener, ElementRef, ChangeDetectorRef } from '@angular/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'app-switch-item', + template: '', + styleUrls: ['./switch-item.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SwitchItemComponent implements OnInit { + @Input() + id: T | null = null; + + @Input() + group = ''; + + @Output() + clicked = new EventEmitter(); + + @HostListener('click', ['$event']) + onClick(e: MouseEvent) { + this.clicked.next(e); + } + + @Input() + borderColorActive: string = 'var(--info-green)'; + + @Input() + borderColorInactive: string = 'var(--button-light)'; + + @HostBinding('style.border-color') + get borderColor() { + if (this.selected) { + return this.borderColorActive; + } + return this.borderColorInactive; + } + + @Input() + @HostBinding('class.disabled') + set disabled(v: any) { + this._disabled = coerceBooleanProperty(v); + } + get disabled() { + return this._disabled; + } + private _disabled = false; + + @Input() + @HostBinding('class.selected') + set selected(v: any) { + const selected = coerceBooleanProperty(v); + if (selected !== this._selected) { + this._selected = selected; + this.selectedChange.next(selected); + } + } + get selected() { + return this._selected; + } + private _selected = false; + + getLabel() { + return this.elementRef.nativeElement.innerText; + } + + @Output() + selectedChange = new EventEmitter(); + + ngOnInit() { + if (this.id === null && isDevMode()) { + throw new Error(`SwitchItemComponent must have an ID`); + } + } + + constructor( + public readonly elementRef: ElementRef, + public readonly changeDetectorRef: ChangeDetectorRef, + ) { } +} diff --git a/desktop/angular/src/app/shared/netquery/.eslintrc.json b/desktop/angular/src/app/shared/netquery/.eslintrc.json new file mode 100644 index 00000000..5ac41541 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "extends": "../../../../.eslintrc.json", + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts" + ], + "parserOptions": { + "project": [ + "projects/safing/ui/tsconfig.lib.json", + "projects/safing/ui/tsconfig.spec.json" + ], + "createDefaultProgram": true + }, + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "sfng", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "sfng", + "style": "kebab-case" + } + ] + } + }, + { + "files": [ + "*.html" + ], + "rules": {} + } + ] +} diff --git a/desktop/angular/src/app/shared/netquery/add-to-filter/add-to-filter.ts b/desktop/angular/src/app/shared/netquery/add-to-filter/add-to-filter.ts new file mode 100644 index 00000000..f87d4910 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/add-to-filter/add-to-filter.ts @@ -0,0 +1,93 @@ +import { ChangeDetectorRef, Directive, HostBinding, HostListener, Input, OnDestroy, OnInit, inject } from "@angular/core"; +import { NetqueryConnection } from "@safing/portmaster-api"; +import { Subscription, combineLatest } from "rxjs"; +import { ActionIndicatorService } from "../../action-indicator"; +import { NetqueryHelper } from "../connection-helper.service"; +import { INTEGRATION_SERVICE } from "src/app/integration"; + +@Directive({ + selector: '[sfngAddToFilter]' +}) +export class SfngNetqueryAddToFilterDirective implements OnInit, OnDestroy { + private subscription = Subscription.EMPTY; + private readonly integration = inject(INTEGRATION_SERVICE); + + @Input('sfngAddToFilter') + key: keyof NetqueryConnection | null = null; + + @Input('sfngAddToFilterValue') + set value(v: any | any[]) { + if (!Array.isArray(v)) { + v = [v] + } + this._values = v; + } + private _values: any[] = []; + + @HostListener('click', ['$event']) + onClick(evt: MouseEvent) { + if (!this.key) { + return + } + + let prevent = false + if (evt.shiftKey) { + this.helper.addToFilter(this.key, this._values); + prevent = true + } else if (evt.ctrlKey) { + this.integration.writeToClipboard(this._values.join(', ')) + .then(() => { + this.uai.success("Copied to clipboard", "Successfully copied " + this._values.join(", ") + " to your clipboard") + }) + .catch(err => { + this.uai.error("Failed to copy to clipboard", this.uai.getErrorMessgae(err)) + }) + + prevent = true + } + + if (prevent) { + evt.preventDefault(); + evt.stopPropagation(); + } + } + + @HostBinding('class.border-dashed') + @HostBinding('class.border-gray-500') + @HostBinding('class.hover:border-gray-700') + readonly _styleHost = true; + + @HostBinding('class.cursor-pointer') + @HostBinding('class.hover:cursor-pointer') + @HostBinding('class.border-b') + @HostBinding('class.select-none') + get shouldHiglight() { + return this.isShiftKeyPressed || this.isCtrlKeyPressed + } + + isShiftKeyPressed = false; + isCtrlKeyPressed = false; + + constructor( + private helper: NetqueryHelper, + private uai: ActionIndicatorService, + private cdr: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.subscription = combineLatest([this.helper.onShiftKey, this.helper.onCtrlKey]) + .subscribe(([isShiftKeyPressed, isCtrlKeyPressed]) => { + if (!this.key) { + return; + } + + this.isShiftKeyPressed = isShiftKeyPressed; + this.isCtrlKeyPressed = isCtrlKeyPressed; + this.cdr.markForCheck(); + }) + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/shared/netquery/add-to-filter/index.ts b/desktop/angular/src/app/shared/netquery/add-to-filter/index.ts new file mode 100644 index 00000000..1dfab44f --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/add-to-filter/index.ts @@ -0,0 +1 @@ +export * from './add-to-filter'; diff --git a/desktop/angular/src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component.ts b/desktop/angular/src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component.ts new file mode 100644 index 00000000..f2b736d9 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component.ts @@ -0,0 +1,358 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Input, OnInit, inject } from '@angular/core'; +import { QueryResult } from '@safing/portmaster-api'; +import * as d3 from 'd3'; + +export interface CircularBarChartConfig { + // stack either holds the attribute name or an accessor function + // to determine which serieses belong to the same stack. + stack: keyof T | ((d: T) => string); + + // series either holds the attribute name of the key or an accessor function. + seriesKey: keyof T | ((d: T) => string); + + seriesLabel?: (s: string) => string; + + // value either holds the attribute name or an accessor function + // to get the value of the series. + value: keyof T | ((d: T) => number); + + colorAsClass?: boolean; + + // the actual series configuration + series?: { + [key: string]: { + color: string; + } + }; + + // The number of ticks for the y axis + ticks?: number; + + formatTick?: (v: number) => string; + + // an optional function to format the value + formatValue?: (stack: string, series: string, value: number, data?: T) => string; + + formatStack?: (sel: d3.Selection, data: T[]) => d3.Selection; +} + + +export function splitQueryResult(results: T[], series: K[]): (QueryResult & { series: string, value: number })[] { + let mapped: (QueryResult & { series: string, value: number })[] = []; + + results.forEach(row => { + series.forEach(seriesKey => { + mapped.push({ + ...row, + value: row[seriesKey], + series: seriesKey as string, + }) + }) + }) + + return mapped +} + +@Component({ + selector: 'sfng-netquery-circular-bar-chart', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CircularBarChartComponent implements OnInit, AfterViewInit { + private readonly elementRef = inject(ElementRef) as ElementRef; + private readonly destroyRef = inject(DestroyRef); + + // D3 related members + private svg?: d3.Selection; + private x?: d3.ScaleBand; + private y?: d3.ScaleRadial; + private height = 0; + private width = 0; + + @Input() + config: CircularBarChartConfig | null = null; + + @Input() + innerRadius?: number; + + @Input() + set data(d: T[] | null) { + this._data = d || []; + + this.prepareChart() + this.render(); + } + private _data: T[] = []; + + ngOnInit(): void { + this.prepareChart() + this.render() + } + + ngAfterViewInit(): void { + const observer = new ResizeObserver(() => { + this.prepareChart() + this.render() + }) + + observer.observe(this.elementRef.nativeElement) + + this.destroyRef.onDestroy(() => observer.disconnect()) + + this.prepareChart() + this.render(); + } + + private prepareChart() { + if (!!this.svg) { + const parent = this.svg.node()?.parentElement + parent?.remove() + } + + const margin = 0.2 + const bbox = this.elementRef.nativeElement.getBoundingClientRect(); + + const marginLeft = bbox.width * margin; + const marginTop = bbox.height * margin; + this.width = bbox.width - 2 * marginLeft; + this.height = bbox.height - 2 * marginTop; + + this.svg = d3.select(this.elementRef.nativeElement) + .append('svg') + .attr('width', "100%") + .attr('height', "100%") + .append('g') + .attr('transform', `translate(${this.width / 2 + marginLeft}, ${this.height / 2 + marginTop})`); + + + this.x = d3.scaleBand() + .range([0, 2 * Math.PI]) + .align(0); + + this.y = d3.scaleRadial() + + // prepare the SVGGElement that we use for rendering + this.svg.append("g") + .attr("id", "chart") + + this.svg.append("g") + .attr("id", "text") + + this.svg.append("g") + .attr("id", "legend") + + this.svg.append("g") + .attr("id", "ticks") + } + + private render() { + const x = this.x; + const y = this.y; + + if (!this.svg || !x || !y) { + console.log("not yet ready") + return; + } + + let stackName: (d: T) => string; + if (typeof this.config?.stack === 'function') { + stackName = this.config.stack; + } else { + stackName = (d: T) => { + return d[this.config!.stack as keyof T] + '' + } + } + + let seriesKey: (d: T) => string; + if (typeof this.config?.seriesKey === 'function') { + seriesKey = this.config!.seriesKey + } else { + seriesKey = (d: T) => { + return d[this.config!.seriesKey as keyof T] + '' + } + } + + let value: (d: T) => number; + if (typeof this.config?.value === 'function') { + value = this.config!.value + } else { + value = (d: T) => { + return +d[this.config!.value as keyof T] + } + } + + let formatValue: Exclude["formatValue"], undefined> = (stack, series, value) => `${stack} ${series}\n${value}` + if (this.config?.formatValue) { + formatValue = this.config.formatValue; + } + + // Prepare the stacked data + const indexed = d3.index(this._data, stackName, seriesKey) + const stackGenerator = d3.stack<[string, d3.InternMap]>() + .keys(d3.union(this._data.map(seriesKey))) + .value((data, key) => { + const obj = data[1].get(key) + if (obj === undefined) { + return 0 + } + + return value(obj); + }) + + const series = stackGenerator(indexed) + + // Prepare the x domain + const labels = new Set(); + this._data.forEach(d => labels.add(stackName(d))); + this.x!.domain(Array.from(labels)) + .range([0, 2 * Math.PI]) + .align(0); + + const innerRadius = this.innerRadius || (() => { + return (series.length * 25) + 20 + })() + + // Prepare the x domain + const outerRadius = Math.min(this.width, this.height) / 2; + const highest = d3.max(series, point => d3.max(point, point => point[1])!)! + this.y!.domain([0, highest]) + .range([innerRadius, outerRadius]); + + + const arc = d3.arc() + .innerRadius((d: any) => y(d[0])) + .outerRadius((d: any) => y(d[1])) + .startAngle((d: any) => x(d.data[0])!) + .endAngle((d: any) => x(d.data[0])! + x.bandwidth()) + .padAngle(0.01) + .padRadius(innerRadius) + + let color: (key: string) => string; + + if (!this.config?.series) { + const colorScale: d3.ScaleOrdinal = d3.scaleOrdinal() + .domain(series.map(d => d.key)) + .range(d3.schemeSpectral) + .unknown("#ccc") + + color = key => colorScale(key); + } else { + color = key => this.config!.series![key].color + } + + this.svg.select("g#chart") + .selectAll() + .data(series) + .join("g") + .call(g => { + if (this.config?.colorAsClass) { + g.attr("fill", "currentColor") + .attr("class", d => color(d.key)) + } else { + g.attr("fill", d => color(d.key)) + } + }) + .selectAll("path") + .data(D => D.map(d => ((d as any).key = D.key, d))) + .join("path") + .attr("d", arc as any) + .append("title") + .text(d => { + const stack = d.data[0] + const series = (d as any).key + const data = d.data[1].get(series); + const seriesValue = data ? value(data) : 0; + + return formatValue(stack, series, seriesValue, data); + }) + + const sumPerLabel = this._data.reduce((map, current) => { + const stack = stackName(current) + let sum = map.get(stack) || 0 + sum += value(current) + map.set(stack, sum) + + return map + }, new Map()); + + this.svg.select("g#text") + .attr("text-anchor", "middle") + .selectAll() + .data(x.domain()) + .join("g") + .attr("text-anchor", d => (x(d)! + x.bandwidth() / 2 + Math.PI) % (2 * Math.PI) < Math.PI ? "end" : "start") + .attr("transform", d => "rotate(" + ((x(d)! + this.x!.bandwidth() / 2) * 180 / Math.PI - 90) + ")" + "translate(" + (y(sumPerLabel.get(d)!) + 10) + ",0)") + .append("g") + .attr("transform", d => (x(d)! + x.bandwidth() / 2 + Math.PI) % (2 * Math.PI) < Math.PI ? "rotate(180)" : "rotate(0)") + .style("font-size", "11px") + .attr("alignment-baseline", "middle") + .attr("fill", "currentColor") + .attr("class", "text-primary cursor-pointer") + .on("mouseenter", function (data) { + d3.select(this) + .classed("underline", true) + }) + .on("mouseleave", function (data) { + d3.select(this) + .classed("underline", false) + }) + .call(g => { + if (!this.config?.formatStack) { + return g.append("text") + .text(d => `${d}`) + } + + return this.config.formatStack(g as any, this._data) + }) + + // y axis + const tickCount = this.config?.ticks || Math.floor((outerRadius - innerRadius) / 20) + const tickFormat = this.config?.formatTick || y.tickFormat(tickCount, "s") + this.svg.select("g#ticks") + .attr("text-anchor", "middle") + .selectAll("g") + .data(y.ticks(tickCount).slice(1)) + .join("g") + .attr("fill", "none") + .call(g => g.append("circle") + .attr("stroke", "#fff") + .attr("stroke-opacity", 0.25) + .attr("r", y)) + .call(g => g.append("text") + .style("font-size", "0.6rem") + .attr("y", d => -y(d)) + .attr("dy", "0.35em") + .attr("fill", "currentColor") + .attr("class", "text-secondary") + .text(tickFormat)) + + // color legend + this.svg.select("g#legend") + .selectAll() + .data(series.map(s => s.key)) + .join("g") + .attr("transform", (d, i, nodes) => `translate(-40,${(nodes.length / 2 - i - 1) * 20})`) + .call(g => g.append("circle") + .attr("r", 5) + .call(g => { + if (this.config?.colorAsClass) { + g.attr("fill", "currentColor") + .attr("class", d => color(d)) + } else { + g.attr("fill", d => color(d)) + } + })) + .call(g => g.append("text") + .attr("x", 12) + .attr("y", 4) + .attr("font-size", "0.6rem") + .attr("fill", "#fff") + .text(d => { + if (!!this.config?.seriesLabel) { + return this.config.seriesLabel(d) + } + + return d + })); + } +} diff --git a/desktop/angular/src/app/shared/netquery/combined-menu.pipe.ts b/desktop/angular/src/app/shared/netquery/combined-menu.pipe.ts new file mode 100644 index 00000000..610e15a1 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/combined-menu.pipe.ts @@ -0,0 +1,16 @@ +import { KeyValue } from '@angular/common'; +import { Pipe, PipeTransform } from "@angular/core"; + +interface Model { + visible: boolean | 'combinedMenu'; +} + +@Pipe({ + pure: true, + name: 'combinedMenu' +}) +export class CombinedMenuPipe implements PipeTransform { + transform(value: KeyValue[], ...args: any[]) { + return value.filter(entry => entry.value?.visible === 'combinedMenu') + } +} diff --git a/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html new file mode 100644 index 00000000..80d604f6 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.html @@ -0,0 +1,322 @@ +
+
+ + Started: + + {{ conn.started | date:'medium'}} + + + + + Ended: + + {{ conn.ended | date:'medium'}} + + + + + + + + Duration: + + {{ [conn.ended, conn.started] | duration }} + + + + + Profile Revision: + + {{ conn.profile_revision }} + + + + + Connection ID: + + {{ conn.id }} + + + + + Verdict: + + {{ verdict[conn.verdict] || 'N/A' }} + + + + + Internal Connection: + + {{ conn.internal ? 'Yes' : 'No' }} + + + + + Local Address: + + {{ conn.local_ip }} + {{ ':'+conn.local_port }} + + +
+ +
+ + Direction: + + + + {{ conn.direction === 'inbound' ? 'Incoming' : 'Outgoing' }} + + + + Protocol: + {{ Protocols[conn.ip_protocol] || 'N/A' }} + + + Encrypted: + {{ conn.encrypted ? 'yes' : 'no' }} + + + SPN Protected: + {{ conn.tunneled ? 'yes' : 'no' }} + + + + Data Received: + {{ conn.bytes_received | bytes }} + + + Data Sent: + {{ conn.bytes_sent | bytes }} + + + + + TLS Version: + {{ tls.Version }} + + + TLS SNI: + {{ tls.SNI }} + + + + + TLS Certificate: + {{ firstChain[0].Subject }} by {{ firstChain[0].Issuer }} + + + Trust-Chain + +
    +
  1. + {{ cert.Subject }} by {{ cert.Issuer }} +
  2. +
+
+
+
+
+
+
+ + +
+ + Domain: + {{dns.Domain}} + + + Query: + {{dns.Question}} + + + + Response: + {{dns.RCode}} + + + + Served from Cache: + {{dns.ServedFromCache ? 'yes' : 'no'}} + + + + Expires: + {{dns.Expires | date:'medium'}} + +
+
+ +
+ + Domain: + + + + + + Scope: + + Internet Peer-to-Peer + Internet Multicast + Device-Local + LAN Peer-to-Peer + LAN Multicast + LAN Peer-to-Peer + + N/A + N/A + N/A + + + {{ conn.direction === 'inbound' ? ' Incoming' : ' Outgoing'}} + + + + Remote Peer: + + + {{ conn.remote_ip || 'DNS Request'}} + {{ ':'+conn.remote_port }} + + + + Country: + {{ conn.country || 'N/A'}} + + + ASN: + {{ conn.asn || 'N/A' }} + + + AS Org: + {{ conn.as_owner || 'N/A' }} + +
+ +
+ + Binary Path: + {{ conn.path }} + + + Reason: + + {{conn.extra_data?.reason?.Msg}} + + + + Applied Setting: + + {{ helper.settings[option] || '' }}  +  from {{ + !!conn.extra_data?.reason?.Profile ? "App" : + "Global" }} Settings + + + + +
+ +
+

SPN Tunnel

+ + + This connection has not been routed through the Safing Privacy Network. + + + +
+
+ + + + +
+
+ + Path Costs: + {{ conn.extra_data?.tunnel?.PathCost }} + + + Routing Algorithm: + {{ conn.extra_data?.tunnel?.RoutingAlg }} + +
+
+ + + The connection was routed through the Safing Privacy Network, but the tunnel information is not available. Try + reloading the connections. + +
+
+ +
+

Data Usage

+ +
+
+ +
+ + + + + + + + +
diff --git a/desktop/angular/src/app/shared/netquery/connection-details/conn-details.scss b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.scss new file mode 100644 index 00000000..f850b003 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.scss @@ -0,0 +1,114 @@ +:host { + section { + display: grid; + + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + + width: 100%; + overflow: hidden; + gap: 1.5rem; + } +} + +section { + &>div { + @apply flex flex-col gap-2 items-start justify-start text-xxs; + + &>span { + @apply space-x-1 text-ellipsis block overflow-hidden w-full; + + &>span:first-child { + @apply text-secondary whitespace-nowrap; + } + + &>span:last-child { + @apply whitespace-nowrap; + } + } + } +} + + +.tunnel-path { + position: relative; + + .line { + position: absolute; + top: 10px; + bottom: 10px; + left: 8px; + width: 1px; + background-color: rgba(255, 255, 255, 0.1); + } + + .node-tag { + border-radius: 1px solid rgba(255, 255, 255, 0.2); + background-color: rgba(255, 255, 255, 0.1); + padding: 2px; + font-size: 85%; + border-radius: 2px; + transform: scale(0.85); + } + + ul { + position: relative; + padding-left: 20px; + + li:not(:last-of-type) { + padding-bottom: 0.35rem; + } + + .ip { + margin-left: 0.35rem; + } + + .hop-icon { + display: inline-block; + margin-left: -17px; + margin-right: 4px; + font-weight: 400; + + &.country { + margin-left: -20px; + } + } + + .hop-title { + margin-right: 2px; + } + + .country { + display: inline-block; + margin-left: -20px; + margin-right: 4px; + + &.unknown { + height: 14px; + width: 16px; + position: relative; + top: 3px; + border: 1px solid rgba(0, 0, 0, 0.25); + opacity: 0.5; + border-radius: 3px; + @apply bg-buttons-icon; + } + } + } +} + + +@keyframes arrow_move { + 0% { + top: 0%; + opacity: 1; + } + + 85% { + opacity: 1; + } + + 100% { + top: 95%; + opacity: 0; + } +} diff --git a/desktop/angular/src/app/shared/netquery/connection-details/conn-details.ts b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.ts new file mode 100644 index 00000000..78dfecda --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-details/conn-details.ts @@ -0,0 +1,147 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, inject } from "@angular/core"; +import { BandwidthChartResult, ConnectionBandwidthChartResult, IPProtocol, IPScope, IsDenied, IsDNSRequest, Netquery, NetqueryConnection, PortapiService, Process, Verdict } from "@safing/portmaster-api"; +import { SfngDialogService } from '@safing/ui'; +import { Subscription } from "rxjs"; +import { ProcessDetailsDialogComponent } from '../../process-details-dialog'; +import { NetqueryHelper } from "../connection-helper.service"; +import { BytesPipe } from "../../pipes/bytes.pipe"; +import { formatDuration } from "../../pipes"; + + + +@Component({ + selector: 'sfng-netquery-conn-details', + styleUrls: ['./conn-details.scss'], + templateUrl: './conn-details.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngNetqueryConnectionDetailsComponent implements OnInit, OnDestroy, OnChanges { + helper = inject(NetqueryHelper) + private readonly portapi = inject(PortapiService) + private readonly dialog = inject(SfngDialogService) + private readonly cdr = inject(ChangeDetectorRef) + private readonly netquery = inject(Netquery) + + @Input() + conn: NetqueryConnection | null = null; + + process: Process | null = null; + + readonly IsDNS = IsDNSRequest; + readonly verdict = Verdict; + readonly Protocols = IPProtocol; + readonly scopes = IPScope; + private _subscription = Subscription.EMPTY; + + formatBytes = (n: d3.NumberValue, seriesKey?: string) => { + let prefix = ''; + if (seriesKey !== undefined) { + prefix = seriesKey === 'incoming' ? 'Received: ' : 'Sent: ' + } + return prefix + new BytesPipe().transform(n.valueOf()) + } + + formatTime = (n: Date) => { + const diff = Math.floor(new Date().getTime() - n.getTime()) + return formatDuration(diff, false, true) + " ago" + } + + tooltipFormat = (n: BandwidthChartResult) => { + const bytes = new BytesPipe().transform + const received = `Received: ${bytes(n?.incoming || 0)}`; + const sent = `Sent: ${bytes(n?.outgoing || 0)}` + + if ((n?.incoming || 0) > (n?.outgoing || 0)) { + return `${received}\n${sent}` + } + return `${sent}\n${received}` + } + + connectionNotice: string = ''; + bwData: ConnectionBandwidthChartResult[] = []; + + ngOnChanges(changes: SimpleChanges) { + if (!!changes?.conn) { + this.updateConnectionNotice(); + this.loadBandwidthChart(); + + if (this.conn?.extra_data?.pid !== undefined) { + this.portapi.get(`network:tree/${this.conn.extra_data.pid}-${this.conn.extra_data.processCreatedAt}`) + .subscribe({ + next: p => { + this.process = p; + this.cdr.markForCheck(); + }, + error: () => { + this.process = null; // the process does not exist anymore + this.cdr.markForCheck(); + } + }) + } else { + this.process = null; + } + } + } + + ngOnInit() { + this._subscription = this.helper.refresh.subscribe(() => { + this.updateConnectionNotice(); + this.loadBandwidthChart(); + + this.cdr.markForCheck(); + }) + } + + ngOnDestroy() { + this._subscription.unsubscribe(); + } + + openProcessDetails() { + this.dialog.create(ProcessDetailsDialogComponent, { + data: this.process, + backdrop: true, + autoclose: true, + }) + } + + private loadBandwidthChart() { + this.bwData = []; + + if (!this.conn) { + this.cdr.markForCheck() + + return; + } + + this.netquery.connectionBandwidthChart([this.conn!.id], 1) + .subscribe(result => { + if (!result[this.conn!.id]?.length) { + return; + } + + this.bwData = result[this.conn!.id]; + + this.cdr.markForCheck(); + }); + } + + private updateConnectionNotice() { + this.connectionNotice = ''; + if (!this.conn) { + return; + } + + if (this.conn!.verdict === Verdict.Failed) { + this.connectionNotice = 'Failed with previous settings.' + return; + } + + if (IsDenied(this.conn!.verdict)) { + this.connectionNotice = 'Blocked by previous settings.'; + } else { + this.connectionNotice = 'Allowed by previous settings.'; + } + + this.connectionNotice += ' You current settings could decide differently.' + } +} diff --git a/desktop/angular/src/app/shared/netquery/connection-details/index.ts b/desktop/angular/src/app/shared/netquery/connection-details/index.ts new file mode 100644 index 00000000..1740e308 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-details/index.ts @@ -0,0 +1 @@ +export * from './conn-details'; diff --git a/desktop/angular/src/app/shared/netquery/connection-helper.service.ts b/desktop/angular/src/app/shared/netquery/connection-helper.service.ts new file mode 100644 index 00000000..fbe1b769 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-helper.service.ts @@ -0,0 +1,537 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable, Renderer2, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { AppProfile, AppProfileService, ConfigService, IPScope, NetqueryConnection, Pin, PossilbeValue, QueryResult, SPNService, Verdict, deepClone, flattenProfileConfig, getAppSetting, setAppSetting } from '@safing/portmaster-api'; +import { BehaviorSubject, Observable, OperatorFunction, Subject, combineLatest } from 'rxjs'; +import { distinctUntilChanged, filter, map, switchMap, take, takeUntil } from 'rxjs/operators'; +import { ActionIndicatorService } from '../action-indicator'; +import { objKeys } from '../utils'; +import { SfngSearchbarFields } from './searchbar'; +import { INTEGRATION_SERVICE } from 'src/app/integration'; + +export const IPScopeNames: { [key in IPScope]: string } = { + [IPScope.Invalid]: "Invalid", + [IPScope.Undefined]: "Undefined", + [IPScope.HostLocal]: "Device Local", + [IPScope.LinkLocal]: "Link Local", + [IPScope.SiteLocal]: "LAN", + [IPScope.Global]: "Internet", + [IPScope.LocalMulticast]: "LAN Multicast", + [IPScope.GlobalMulitcast]: "Internet Multicast" +} + +export interface LocalAppProfile extends AppProfile { + FlatConfig: { [key: string]: any } +} + +@Injectable() +export class NetqueryHelper { + readonly settings: { [key: string]: string } = {}; + + refresh = new Subject(); + + private onShiftKey$ = new BehaviorSubject(false); + private onCtrlKey$ = new BehaviorSubject(false); + private addToFilter$ = new Subject(); + private destroy$ = new Subject(); + private appProfiles$ = new BehaviorSubject([]); + private spnMapPins$ = new BehaviorSubject(null); + private readonly integration = inject(INTEGRATION_SERVICE); + + readonly onShiftKey: Observable; + readonly onCtrlKey: Observable; + + constructor( + private router: Router, + private profileService: AppProfileService, + private configService: ConfigService, + private actionIndicator: ActionIndicatorService, + private renderer: Renderer2, + private spnService: SPNService, + @Inject(DOCUMENT) private document: Document, + ) { + const cleanupKeyDown = this.renderer.listen(this.document, 'keydown', (event: KeyboardEvent) => { + if (event.shiftKey) { + this.onShiftKey$.next(true) + } + if (event.ctrlKey) { + this.onCtrlKey$.next(true); + } + }); + + const cleanupKeyUp = this.renderer.listen(this.document, 'keyup', () => { + this.onShiftKey$.next(false); + this.onCtrlKey$.next(false); + }) + + const windowBlur = this.renderer.listen(window, 'blur', () => { + this.onShiftKey$.next(false); + this.onCtrlKey$.next(false); + }) + + this.destroy$.subscribe({ + complete: () => { + cleanupKeyDown(); + cleanupKeyUp(); + windowBlur(); + } + }) + + this.onShiftKey = this.onShiftKey$ + .pipe(distinctUntilChanged()); + + this.onCtrlKey = this.onCtrlKey$ + .pipe(distinctUntilChanged()); + + this.configService.query('') + .subscribe(settings => { + settings.forEach(setting => { + this.settings[setting.Key] = setting.Name; + }); + this.refresh.next(); + }); + + // watch all application profiles + this.profileService.watchProfiles() + .pipe(takeUntil(this.destroy$)) + .subscribe(profiles => { + this.appProfiles$.next((profiles || []).map(p => { + return { + ...p, + FlatConfig: flattenProfileConfig(p.Config), + } + })) + }); + + this.spnService.watchPins() + .pipe(takeUntil(this.destroy$)) + .subscribe(pins => { + this.spnMapPins$.next(pins); + }) + } + + decodePrettyValues(field: keyof NetqueryConnection, values: any[]): any[] { + if (field === 'verdict') { + return values.map(val => Verdict[val]).filter(value => value !== undefined); + } + + if (field === 'scope') { + return values.map(val => { + // check if it's a value of the IPScope enum + const scopeValue = IPScope[val]; + if (!!scopeValue) { + return scopeValue; + } + + // otherwise check if it's pretty name of the scope translation + val = `${val}`.toLocaleLowerCase(); + return objKeys(IPScopeNames).find(scope => IPScopeNames[scope].toLocaleLowerCase() === val) + }).filter(value => value !== undefined); + } + + if (field === 'allowed') { + return values.map(val => { + if (typeof val !== 'string') { + return val + } + + switch (val.toLocaleLowerCase()) { + case 'yes': + return true + case 'no': + return false + case 'n/a': + case 'null': + return null + default: + return val + } + }) + } + + if (field === 'exit_node') { + const lm = new Map(); + (this.spnMapPins$.getValue() || []) + .forEach(pin => lm.set(pin.Name, pin)); + + return values.map(val => lm.get(val)?.ID || val) + } + + return values; + } + + attachProfile(): OperatorFunction { + return source => combineLatest([ + source, + this.appProfiles$, + ]).pipe( + map(([items, profiles]) => { + let lm = new Map(); + profiles.forEach(profile => { + lm.set(`${profile.Source}/${profile.ID}`, profile) + }) + + return items.map(item => { + if ('profile' in item) { + item.__profile = lm.get(item.profile!) + } + + return item; + }) + }) + ) + } + + attachPins(): OperatorFunction { + return source => combineLatest([ + source, + this.spnMapPins$ + .pipe( + filter(result => result !== null), + take(1), + ), + ]).pipe( + map(([items, pins]) => { + let lm = new Map(); + pins!.forEach(pin => { + lm.set(pin.ID, pin) + }) + + return items.map(item => { + if ('exit_node' in item) { + item.__exitNode = lm.get(item.exit_node!) + } + + return item; + }) + }) + ) + } + + encodeToPossibleValues(field: string): OperatorFunction { + return source => combineLatest([ + source, + this.appProfiles$, + this.spnMapPins$, + ]).pipe( + map(([items, profiles, pins]) => { + // convert profile IDs to profile name + if (field === 'profile') { + let lm = new Map(); + profiles.forEach(profile => { + lm.set(`${profile.Source}/${profile.ID}`, profile) + }) + + return items.map((item: any) => { + const profile = lm.get(item.profile!) + return { + Name: profile?.Name || `${item.profile}`, + Value: item.profile!, + Description: '', + __profile: profile || null, + ...item, + } + }) + } + + // convert verdict identifiers to their pretty name. + if (field === 'verdict') { + return items.map(item => { + if (Verdict[item.verdict!] === undefined) { + return null + } + + return { + Name: Verdict[item.verdict!], + Value: item.verdict, + Description: '', + ...item + } + }) + } + + // convert the IP scope identifier to a pretty name + if (field === 'scope') { + return items.map(item => { + if (IPScope[item.scope!] === undefined) { + return null + } + + return { + Name: IPScopeNames[item.scope!], + Value: item.scope, + Description: '', + ...item + } + }) + } + + if (field === 'allowed') { + return items + // we remove any "null" value from allowed here as it may happen for a really short + // period of time and there's no reason to actually filter for them because + // from showing a "null" value to the user clicking it the connection will have been + // verdicted and thus no results will show up for "null". + .filter(item => typeof item.allowed === 'boolean') + .map(item => { + return { + Name: item.allowed ? 'Yes' : 'No', + Value: item.allowed, + Description: '', + ...item + } + }) + } + + if (field === 'exit_node') { + const lm = new Map(); + pins!.forEach(pin => lm.set(pin.ID, pin)); + + return items.map(item => { + const pin = lm.get(item.exit_node!); + return { + Name: pin?.Name || item.exit_node, + Value: item.exit_node, + Description: 'Operated by ' + (pin?.VerifiedOwner || 'N/A'), + ...item + } + }) + } + + // the rest is just converted into the {@link PossibleValue} form + // by using the value as the "Name". + return items.map(item => ({ + Name: `${item[field]}`, + Value: item[field], + Description: '', + ...item, + })) + }), + // finally, remove any values that have been mapped to null in the above stage. + // this may happen for values that are not valid for the given model field (i.e. using "Foobar" for "verdict") + map(results => { + return results.filter(val => !!val) + }) + ) + } + + dispose() { + this.onShiftKey$.complete(); + + this.destroy$.next(); + this.destroy$.complete(); + } + + /** Emits added fields whenever addToFilter is called */ + onFieldsAdded(): Observable { + return this.addToFilter$.asObservable(); + } + + /** Adds a new filter to the current query */ + addToFilter(key: string, value: any[]) { + this.addToFilter$.next({ + [key]: value, + }) + } + + /** + * @private + * Returns the class used to color the connection's + * verdict. + * + * @param conn The connection object + */ + getVerdictClass(conn: NetqueryConnection): string { + return Verdict[conn.verdict]?.toLocaleLowerCase() || `unknown-verdict<${conn.verdict}>`; + } + + /** + * @private + * Redirect the user to a settings key in the application + * profile. + * + * @param key The settings key to redirect to + */ + redirectToSetting(setting: string, conn: NetqueryConnection, globalSettings = false) { + const reason = conn.extra_data?.reason; + if (!reason) { + return; + } + + if (!setting) { + setting = reason.OptionKey; + } + + if (!setting) { + return; + } + + if (globalSettings) { + this.router.navigate( + ['/', 'settings'], { + queryParams: { + setting: setting, + } + }) + return; + } + + let profile = conn.profile + + if (!!reason.Profile) { + profile = reason.Profile; + } + + if (profile.startsWith("core:profiles/")) { + profile = profile.replace("core:profiles/", "") + } + + this.router.navigate( + ['/', 'app', ...profile.split("/")], { + queryParams: { + tab: 'settings', + setting: setting, + } + }) + } + + /** + * @private + * Redirect the user to "outgoing rules" setting in the + * application profile/settings. + */ + redirectToRules(conn: NetqueryConnection) { + if (conn.direction === 'inbound') { + this.redirectToSetting('filter/serviceEndpoints', conn); + } else { + this.redirectToSetting('filter/endpoints', conn); + } + } + + /** + * @private + * Dump a connection to the console + * + * @param conn The connection to dump + */ + async dumpConnection(conn: NetqueryConnection) { + // Copy to clip-board if supported + try { + await this.integration.writeToClipboard(JSON.stringify(conn, undefined, " ")) + this.actionIndicator.info("Copied to Clipboard") + } catch (err: any) { + this.actionIndicator.error("Copy to Clipboard Failed", err?.message || JSON.stringify(err)) + } + } + + /** + * @private + * Creates a new "block domain" outgoing rules + */ + blockAll(domain: string, conn: NetqueryConnection) { + /* Deactivate until exact behavior is specified. + if (this.isDomainBlocked(domain)) { + this.actionIndicator.info(domain + ' already blocked') + return; + } + */ + + domain = domain.replace(/\.+$/, ''); + const newRule = `- ${domain}`; + this.updateRules(newRule, true, conn) + } + + /** + * @private + * Removes a "block domain" rule from the outgoing rules + */ + unblockAll(domain: string, conn: NetqueryConnection) { + /* Deactivate until exact behavior is specified. + if (!this.isDomainBlocked(domain)) { + this.actionIndicator.info(domain + ' already allowed') + return; + } + */ + + domain = domain.replace(/\.+$/, ''); + const newRule = `+ ${domain}`; + this.updateRules(newRule, true, conn); + } + + /** + * Updates the outgoing rule set and either creates or deletes + * a rule. If a rule should be created but already exists + * it is moved to the top. + * + * @param newRule The new rule to create or delete. + * @param add Whether or not to create or delete the rule. + */ + private updateRules(newRule: string, add: boolean, conn: NetqueryConnection) { + if (!conn.profile) { + return + } + + let key = 'filter/endpoints'; + if (conn.direction === 'inbound') { + key = 'filter/serviceEndpoints' + } + + this.profileService.getAppProfile(conn.profile) + .pipe( + switchMap(profile => { + let rules = getAppSetting(profile.Config, key) || []; + rules = rules.filter(rule => rule !== newRule); + + if (add) { + rules.splice(0, 0, newRule) + } + + const newProfile = deepClone(profile); + + if (newProfile.Config === null || newProfile.Config === undefined) { + newProfile.Config = {} + } + + setAppSetting(newProfile.Config, key, rules); + + return this.profileService.saveProfile(newProfile) + }) + ) + .subscribe({ + next: () => { + if (add) { + this.actionIndicator.success('Rules Updated', 'Successfully created a new rule.') + } else { + this.actionIndicator.success('Rules Updated', 'Successfully removed matching rule.') + } + }, + error: err => { + this.actionIndicator.error('Failed to update rules', JSON.stringify(err)) + } + }); + } + + /** + * Iterates of all outgoing rules and collects which domains are blocked. + * It stops collecting domains as soon as the first "allow something" rule + * is hit. + */ + // FIXME + /* + private collectBlockedDomains() { + let blockedDomains = new Set(); + + const rules = getAppSetting(this.profile!.profile!.Config, 'filter/endpoints') || []; + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (rule.startsWith('+ ')) { + break; + } + + blockedDomains.add(rule.slice(2)) + } + + this.blockedDomains = Array.from(blockedDomains) + } + */ +} diff --git a/desktop/angular/src/app/shared/netquery/connection-row/conn-row.html b/desktop/angular/src/app/shared/netquery/connection-row/conn-row.html new file mode 100644 index 00000000..3c721b0b --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-row/conn-row.html @@ -0,0 +1,146 @@ +
+ + + + + + + + + + + + + + + Internet Peer-to-Peer + Internet Multicast + Device-Local + LAN Peer-to-Peer + LAN Multicast + LAN Peer-to-Peer + + N/A + N/A + N/A + + + {{ conn.direction === 'inbound' ? ' Incoming' : ' Outgoing'}} + + +
+ + +
+ + {{ conn.country | countryName }} +
+
+ + + +
+ + + {{ conn.__profile.Name }} + +
+ +
+ + + + + {{ conn.remote_ip }} :{{ conn.remote_port }} + + + + DNS Request + + +
+ + + + + +
+ + + + + App Setting + + + + Global Setting + + + + + + + Allow {{ conn.domain ? 'Domain' : 'IP'}} + + + + + Block {{ conn.domain ? 'Domain' : 'IP '}} + + + + + Copy JSON + +
diff --git a/desktop/angular/src/app/shared/netquery/connection-row/conn-row.scss b/desktop/angular/src/app/shared/netquery/connection-row/conn-row.scss new file mode 100644 index 00000000..0d737830 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-row/conn-row.scss @@ -0,0 +1,43 @@ +:host { + @apply w-full flex-grow gap-4 grid justify-start items-center overflow-hidden; + + grid-template-columns: + 1fr 1fr 1fr 2rem; + + grid-auto-rows: 1.5rem; + grid-template-rows: none; + + &>* { + @apply overflow-hidden whitespace-nowrap; + + &>*:last-child { + @apply overflow-hidden text-ellipsis; + } + } + + --app-icon-size: 20px; +} + +:host-context(.min-width-768px) { + :host { + grid-template-columns: + 1fr 4rem 1fr 1fr 5rem 2rem; + ; + } +} + +:host-context(.min-width-1024px) { + :host { + grid-template-columns: + 1fr 4rem 1fr 1fr 5rem 0.5fr 2rem; + ; + } +} + +:host-context(.min-width-1280px) { + :host { + grid-template-columns: + 1fr 4rem 1fr 1fr 8rem 1fr 2rem; + ; + } +} diff --git a/desktop/angular/src/app/shared/netquery/connection-row/conn-row.ts b/desktop/angular/src/app/shared/netquery/connection-row/conn-row.ts new file mode 100644 index 00000000..b841d116 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-row/conn-row.ts @@ -0,0 +1,78 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { AppProfile, IPScope, NetqueryConnection, Verdict } from "@safing/portmaster-api"; +import { interval, Subscription } from "rxjs"; +import { share, startWith } from "rxjs/operators"; +import { NetqueryHelper } from "../connection-helper.service"; + +interface ProfileAttachedConnection extends NetqueryConnection { + __profile?: AppProfile; +} + +@Component({ + selector: 'sfng-netquery-connection-row', + templateUrl: './conn-row.html', + styleUrls: [ + './conn-row.scss' + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngNetqueryConnectionRowComponent implements OnInit, OnDestroy { + readonly scopes = IPScope; + readonly verdicts = Verdict; + + @Input() + set conn(c: ProfileAttachedConnection) { + this._conn = c; + } + get conn() { return this._conn; } + _conn!: ProfileAttachedConnection; + + @Input() + activeRevision: number | undefined = 0; + + get isOutdated() { + // FIXME(ppacher) + return false; + /* + if (!this.conn || !this.helper.profile) { + return false; + } + if (this.helper.profile.currentProfileRevision === -1) { + // we don't know the revision counter yet ... + return false; + } + return this.conn.profile_revision !== this.helper.profile.currentProfileRevision; + */ + } + + /* timeAgoTicker ticks every 10000 seconds to force a refresh + of the timeAgo pipes */ + timeAgoTicker: number = 0; + + private _subscription = Subscription.EMPTY; + + constructor( + public helper: NetqueryHelper, + private changeDetectorRef: ChangeDetectorRef, + ) { } + + ngOnInit() { + this._subscription = new Subscription(); + + const tickerSub = interval(10000).pipe( + startWith(-1), + share() + ).subscribe(i => this.timeAgoTicker = i); + + const helperSub = this.helper.refresh.subscribe(() => { + this.changeDetectorRef.markForCheck(); + }) + + this._subscription.add(helperSub); + this._subscription.add(tickerSub); + } + + ngOnDestroy() { + this._subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/shared/netquery/connection-row/index.ts b/desktop/angular/src/app/shared/netquery/connection-row/index.ts new file mode 100644 index 00000000..adbe1933 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/connection-row/index.ts @@ -0,0 +1 @@ +export * from './conn-row'; diff --git a/desktop/angular/src/app/shared/netquery/index.ts b/desktop/angular/src/app/shared/netquery/index.ts new file mode 100644 index 00000000..84660587 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/index.ts @@ -0,0 +1,2 @@ +export * from './netquery.component'; +export * from './netquery.module'; diff --git a/desktop/angular/src/app/shared/netquery/line-chart/index.ts b/desktop/angular/src/app/shared/netquery/line-chart/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/desktop/angular/src/app/shared/netquery/line-chart/line-chart.ts b/desktop/angular/src/app/shared/netquery/line-chart/line-chart.ts new file mode 100644 index 00000000..8d95a061 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/line-chart/line-chart.ts @@ -0,0 +1,596 @@ +import { coerceBooleanProperty, coerceNumberProperty, coerceStringArray } from '@angular/cdk/coercion'; +import { AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Input, OnChanges, OnInit, SimpleChanges, inject } from '@angular/core'; +import { BandwidthChartResult, ChartResult } from '@safing/portmaster-api'; +import * as d3 from 'd3'; +import { Selection } from 'd3'; +import { AppComponent } from 'src/app/app.component'; +import { formatDuration, timeAgo } from '../../pipes'; +import { objKeys } from '../../utils'; +import { BytesPipe } from '../../pipes/bytes.pipe'; + +export interface SeriesConfig { + lineColor: string; + areaColor?: string; +} + +export interface Marker { + text: string; + time: Date | number | string; +} + +export interface ChartConfig { + series: { + [key in Exclude]?: SeriesConfig; + }, + time?: { + from: number | string | Date; + to?: number | string | Date; + }, + fromMargin?: number; + toMargin?: number; + valueFormat?: (n: d3.NumberValue, seriesKey?: string) => string, + tooltipFormat?: (data: T) => string; + timeFormat?: (n: Date) => string, + showDataPoints?: boolean; + fillEmptyTicks?: { + interval: number; + }, + verticalMarkers?: Marker[]; +} + +function coerceDate(d: Date | number | string): Date { + if (typeof d === 'string') { + return new Date(d) + } + + if (d instanceof Date) { + return d + } + + if (d < 0) { + return new Date((new Date()).getTime() + d * 1000) + } + + return new Date(d * 1000); +} + +export const DefaultChartConfig: ChartConfig = { + series: { + value: { + lineColor: 'text-green-200', + areaColor: 'text-green-100 text-opacity-25' + }, + countBlocked: { + lineColor: 'text-red-200', + areaColor: 'text-red-100 text-opacity-25' + } + }, +} + +export const DefaultBandwidthChartConfig: ChartConfig> = { + series: { + outgoing: { + lineColor: 'text-deepPurple-500', + areaColor: 'text-deepPurple-700 text-opacity-5', + }, + incoming: { + lineColor: 'text-cyan-800', + areaColor: 'text-cyan-700 text-opacity-5', + }, + }, + time: { + from: -10 * 60, + }, + valueFormat: (n: d3.NumberValue, seriesKey?: string) => { + let prefix = ''; + if (seriesKey !== undefined) { + prefix = seriesKey === 'incoming' ? 'Received: ' : 'Sent: ' + } + return prefix + new BytesPipe().transform(n.valueOf()) + }, + timeFormat: (n: Date) => { + const diff = Math.floor(new Date().getTime() - n.getTime()) + return formatDuration(diff, false, true) + " ago" + }, + tooltipFormat: (n: BandwidthChartResult) => { + const bytes = new BytesPipe().transform + const received = `Received: ${bytes(n?.incoming || 0)}`; + const sent = `Sent: ${bytes(n?.outgoing || 0)}` + + if ((n?.incoming || 0) > (n?.outgoing || 0)) { + return `${received}\n${sent}` + } + return `${sent}\n${received}` + }, + showDataPoints: true, + fillEmptyTicks: { + interval: 60 + }, +} + +export interface SeriesData { + timestamp: number; +} + +@Component({ + selector: 'sfng-netquery-line-chart', + styles: [ + ` + :host { + @apply block h-full w-full; + } + ` + ], + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngNetqueryLineChartComponent implements OnChanges, OnInit, AfterViewInit { + private destroyRef = inject(DestroyRef); + + @Input() + data: D[] = []; + + private preparedData: D[] = []; + + private width = 700; + private height = 250; + + @Input() + set margin(v: any) { + this._margin = coerceNumberProperty(v); + } + get margin() { return this._margin; } + private _margin = 0; + + @Input() + config!: ChartConfig; + + svg!: Selection; + svgInner!: Selection; + yScale!: d3.ScaleLinear; + xScale!: d3.ScaleTime; + xAxis!: Selection; + yAxis!: Selection; + + @Input() + set showAxis(v: any) { + this._showAxis = coerceBooleanProperty(v); + } + get showAxis() { + return this._showAxis; + } + private _showAxis = true; + + constructor( + public chartElem: ElementRef, + private app: AppComponent + ) { } + + ngOnInit() { + if (!this.config) { + this.config = DefaultChartConfig as any; + } + + const observer = new ResizeObserver(() => { + this.redraw(); + }) + + observer.observe(this.chartElem.nativeElement) + + this.destroyRef.onDestroy(() => observer.disconnect()) + + } + + ngAfterViewInit(): void { + requestAnimationFrame(() => { + this.redraw() + }) + } + + ngOnChanges(changes: SimpleChanges): void { + if (Object.prototype.hasOwnProperty.call(changes, 'config') && this.config) { + this.redraw() + return + } + + if (Object.prototype.hasOwnProperty.call(changes, 'data') && this.data) { + this.drawChart(); + } + } + + get yMargin() { + if (this.showAxis) { + return 16; + } + return 0; + } + + redraw(event?: Event) { + if (!!this.svg) { + this.svg.remove(); + } + + this.initializeChart(); + this.drawChart(); + } + + private initializeChart(): void { + this.width = this.chartElem.nativeElement.getBoundingClientRect().width; + this.height = this.chartElem.nativeElement.getBoundingClientRect().height; + + this.svg = d3 + .select(this.chartElem.nativeElement) + .append('svg') + + this.svg.attr('width', this.width); + this.svg.attr('height', this.height); + + this.svgInner = this.svg + .append('g') + .attr('height', '100%'); + + this.yScale = d3 + .scaleLinear() + + this.xScale = d3.scaleTime(); + + // setup event handlers to higlight the closest data points + let lastClosestIndex = -1; + + if (this.config.showDataPoints) { + const self = this; + this.svg + .on("mousemove", function (event: MouseEvent) { + let x = d3.pointer(event)[0]; + + let closest = self.data.reduce((best, value, idx) => { + let absx = Math.abs(self.xScale(new Date(value.timestamp * 1000)) - x); + if (absx < best.value) { + return { index: idx, value: absx, timestamp: self.data[idx].timestamp } + } + + return best + + }, { index: 0, value: Number.MAX_SAFE_INTEGER, timestamp: 0 }) + + if (lastClosestIndex === closest.index) { + return; + } + lastClosestIndex = closest.index; + + if (self.config.tooltipFormat) { + // append a title to the parent SVG, this is a quick-fix for showing some + // information on the highlighted points + // TODO(ppacher): actually render a nice tooltip there. + let tooltip = self.svg + .select('title.tooltip') + + if (tooltip.empty()) { + tooltip = self.svg.append("title") + .attr("class", "tooltip") + } + + tooltip + .text(self.config.tooltipFormat!(self.data[closest.index])) + } + + self.svgInner + .select(".vertical-marker") + .selectAll(".mouse-position") + .remove() + + self.svgInner + .select(".vertical-marker") + .append("line") + .classed("mouse-position", true) + .attr("x1", d => self.xScale(closest.timestamp * 1000)) + .attr("y1", -10) + .attr("x2", d => self.xScale(closest.timestamp * 1000)) + .attr("y2", self.height - self.yMargin) + .classed("text-secondary text-opacity-50", true) + .attr("stroke", "currentColor") + .attr("stroke-width", 1) + .attr("stroke-dasharray", 2) + + self.svgInner + .select(".points") + .selectAll("circle") + .classed("opacity-100", d => self.xScale.invert(d[0]).getTime() === closest.timestamp * 1000) + }) + .on("mouseleave", function () { + lastClosestIndex = -1; + + self.svg.select("title.tooltip") + .remove() + + self.svg.select("line.mouse-position") + .remove() + + self.svgInner + .select(".points") + .selectAll("circle") + .attr("r", 4) + .classed("opacity-100", false) + }) + } + + objKeys(this.config.series).forEach(seriesKey => { + const seriesConfig = this.config.series[seriesKey]!; + + if (seriesConfig.areaColor) { + this.svgInner + .append('path') + .attr("fill", "currentColor") + .attr("class", `area-${String(seriesKey)} ${(seriesConfig.areaColor || '')}`) + } + + this.svgInner + .append('g') + .append('path') + .style('fill', 'none') + .style('stroke', 'currentColor') + .style('stroke-width', '1') + .attr('class', `line-${String(seriesKey)} ${seriesConfig.lineColor}`) + }) + + this.svgInner.append("g") + .attr("class", "vertical-marker") + + this.svgInner.append("g") + .attr("class", "points") + + if (this.showAxis) { + this.yAxis = this.svgInner + .append('g') + .attr('id', 'y-axis') + .attr('class', 'text-secondary text-opacity-75 ') + .style('transform', 'translate(' + (this.width - this.yMargin) + 'px, 0)'); + + this.xAxis = this.svgInner + .append('g') + .attr('id', 'x-axis') + .attr('class', 'text-secondary text-opacity-50 ') + .style('transform', 'translate(0, ' + (this.height - this.yMargin) + 'px)'); + } + } + + private getTimeRange(): { from: Date, to: Date } { + const time = { + from: this.data[0]?.timestamp || 0, + to: this.data[this.data.length - 1]?.timestamp || 0, + }; + + if (!!this.config.time) { + time.from = coerceDate(this.config.time.from).getTime() / 1000 + + if (this.config.fromMargin) { + time.from = time.from - this.config.fromMargin + } + + if (this.config.time.to) { + time.to = coerceDate(this.config.time.to).getTime() / 1000 + + if (this.config.toMargin) { + time.to = time.to + this.config.toMargin + } + } + } + + return { + from: new Date(time.from * 1000), + to: new Date(time.to * 1000) + }; + } + + private prepareDataSet(data: D[], time: { from: Date, to: Date }) { + const toTimestamp = Math.round(time.to.getTime() / 1000) + const fromTimestamp = Math.round(time.from.getTime() / 1000) + + // first, filter out all elements that are before or after the to date + data = data.filter(d => { + return d.timestamp >= fromTimestamp && d.timestamp <= toTimestamp + }) + + // check if we need to fill empty ticks + if (!this.config.fillEmptyTicks) { + return data; + } + + const interval = this.config.fillEmptyTicks.interval; + + const filledData: D[] = []; + const addEmpty = (ts: number) => { + const empty: any = { + timestamp: ts, + } + + Object.keys(this.config.series) + .forEach(s => empty[s] = 0) + + filledData.push(empty) + } + + if (!data.length) { + return []; + } + + let firstElement = data[0].timestamp; + if (this.config.time?.from) { + firstElement = Math.round(coerceDate(this.config.time.from).getTime() / 1000) + } + + // add empty values for the start-time until the first element / or the start tme + let lastTimeStamp = fromTimestamp - interval; + for (let ts = lastTimeStamp; ts <= firstElement; ts += interval) { + addEmpty(ts) + } + + // add emepty vaues for each missing tick during the dataset + lastTimeStamp = firstElement; + for (let idx = 0; idx < data.length; idx++) { + const elem = data[idx] + const elemTs = elem.timestamp; + + for (let ts = lastTimeStamp + interval; ts < elemTs; ts += interval) { + addEmpty(ts) + } + + filledData.push(elem) + lastTimeStamp = elemTs + } + + // if there's a specified end-time, add empty ticks from the last datapoint + // to the end-time + if (this.config.time?.to) { + for (let ts = lastTimeStamp + interval; ts <= toTimestamp; ts += interval) { + addEmpty(ts) + } + } + + return filledData + } + + private drawChart(): void { + if (!this.svg) { + return; + } + + if (!this.data?.length) { + return; + } + + this.data.sort((a, b) => a.timestamp - b.timestamp) + + // determine the time range that should be displayed. + const time = this.getTimeRange(); + + // fill empty ticks depending on the configuration. + this.preparedData = this.prepareDataSet(this.data, time) + + this.xScale + .range([0, this.width - this.yMargin]) + .domain([time.from, time.to]); + + this.yScale + .range([0, this.height - this.yMargin]) + .domain([ + d3.max(this.preparedData.map(d => { + return d3.max( + objKeys(this.config.series) + .map(series => { + return d[series] as number + }) + )! + }))! * 1.3, // 30% margin to top + 0 + ]) + + if (this.showAxis) { + const xAxis = d3 + .axisBottom(this.xScale) + .ticks(5) + .tickFormat((val, idx) => { + if (!!this.config.timeFormat) { + return this.config.timeFormat(val as any) + } + return timeAgo(val as any); + }) + + this.xAxis.call(xAxis); + + const yAxis = d3 + .axisLeft(this.yScale) + .ticks(2) + .tickFormat(d => ((this.config.valueFormat || this.yScale.tickFormat(2)) as any)(d, undefined)) + + this.yAxis.call(yAxis); + } + + const line = d3 + .line() + .x(d => d[0]) + .y(d => d[1]) + .curve(d3.curveMonotoneX); + + // define the area + const area = d3.area() + .x(d => d[0]) + .y0(this.height - this.yMargin) + .y1(d => d[1]) + .curve(d3.curveMonotoneX) + + // render vertical markers + const markers = (this.config.verticalMarkers || []) + .filter(marker => !!marker.time) + .map(marker => ({ + text: marker.text, + time: coerceDate(marker.time) + })); + + this.svgInner.select('.vertical-marker') + .selectAll("line.marker") + .data(markers) + .join("line") + .classed("marker", true) + .attr("x1", d => this.xScale(d.time)) + .attr("y1", -10) + .attr("x2", d => this.xScale(d.time)) + .attr("y2", this.height - this.yMargin) + .classed("text-secondary text-opacity-50", true) + .attr("stroke", "currentColor") + .attr("stroke-width", 3) + .attr("stroke-dasharray", 4) + .append("title") + .text(d => d.text) + + // FIXME(ppacher): somehow d3 does not recognize which data points must be removed + // or re-placed. For now, just remove them all + this.svgInner + .select('.points') + .selectAll("circle") + .remove() + + objKeys(this.config.series) + .forEach(seriesKey => { + const config = this.config.series[seriesKey]!; + + let points: [number, number][] = this.preparedData + .map(d => [ + this.xScale(new Date(d.timestamp * 1000)), + this.yScale((d as any)[seriesKey] || 0), + ]) + + let data: [number, number][] = this.preparedData + .map(d => [ + this.xScale(new Date(d.timestamp * 1000)), + this.yScale((d as any)[seriesKey] || 0), + ]) + + if (config.areaColor) { + this.svgInner.selectAll(`.area-${String(seriesKey)}`) + .data([data]) + .attr('d', area(data)) + } + + this.svgInner.select(`.line-${String(seriesKey)}`) + .attr('d', line(data)) + + if (this.config?.showDataPoints) { + this.svgInner + .select('.points') + .selectAll(`circle.point-${String(seriesKey)}`) + .data(points) + .enter() + .append("circle") + .classed(`points-${String(seriesKey)}`, true) + .attr("r", "4") + .attr("fill", "currentColor") + .attr("class", `opacity-0 ${config.lineColor}`) + .attr("cx", d => d[0]) + .attr("cy", d => d[1]) + .append("title") + .text(d => ((this.config.valueFormat || this.yScale.tickFormat(2)) as any)(this.yScale.invert(d[1]), String(seriesKey))) + } + }) + } +} diff --git a/desktop/angular/src/app/shared/netquery/netquery.component.html b/desktop/angular/src/app/shared/netquery/netquery.component.html new file mode 100644 index 00000000..6b5dfbf0 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/netquery.component.html @@ -0,0 +1,388 @@ +
+ + + + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + + + + + Loading ... + + + + + + + + + {{ value.Name || 'N/A' }} + + + + #{{ value.count }} connections + + + + + + + + + + + + {{ item.value || 'N/A' }} + + + + + + + + + + + + + + + +
+

+ Filter by {{ model.value!.menuTitle || model.key }} + +

+
    +
  • + + + {{ value.Name }} + + + + + + +
  • +
+
+
+
+ + +
+ +
+ Search History: + + + + + Quick Settings +
    +
  • + {{ qds.name }} +
  • +
+
+
+ +
+ + + + + {{ keyTranslation[value] || value }} + + + + + + + + + {{ keyTranslation[value] || value }} + + + + +
+
+ +
+
+

Connections

+
+ +
+
+ +
+

Data Usage

+
+ +
+
+ +
+ Loading Chart +
+
+ + +
+ + + + + + + + + + + + {{ keyTranslation[key] || key }} + + + {{ data[key] || 'N/A' }} + + + + + + {{ data.__profile.Name }} + + + + + + ( + + ) + + {{ data.__exitNode.Name }} + + + + + + {{ data[key] || 'N/A' }} + + + + + + + +
+
+ + + Use as filter + App Settings + + +
+ + + + + + +
+
+ + + + + +
+
+ +
+ {{ totalResultCount }} Results + of {{totalConnCount}} total connections + + + + Last Reload: {{ lastReload | timeAgo:(lastReloadTicker|async) }} + +
+ + + + + + +
+ + + + + +
+ All connections ended more than 10 minutes ago and have been removed. +
+ + + + +
+ +
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+ + + +
+ + + + + + Loading connections ... +
+
+ +
+ + + + Pro Tip: + + + +
+ + + Press +
CTRL + Space
+ on any page to bring up the quick search box. +
+ + + Use your keyboard arrows to navigate through the search suggestions. Press +
ENTER
to search for the suggestion or use +
Shift + Enter
to add it to the search text. +
+ + + Inside the search box, use +
Ctrl + Space
to force loading suggestions. +
+ + + Use +
Shift + Click
to add connection attributes to the current filter. +
+ + + Hold +
Shift
to highlight attributes that can be used in the filter. +
+ + + Hold +
CTRL
and click attributes to copy them to the clipboard. +
diff --git a/desktop/angular/src/app/shared/netquery/netquery.component.ts b/desktop/angular/src/app/shared/netquery/netquery.component.ts new file mode 100644 index 00000000..a09d18eb --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/netquery.component.ts @@ -0,0 +1,1271 @@ +import { coerceArray } from "@angular/cdk/coercion"; +import { FormatWidth, formatDate, getLocaleDateFormat, getLocaleId } from "@angular/common"; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, LOCALE_ID, OnDestroy, OnInit, Output, QueryList, TemplateRef, TrackByFunction, ViewChildren, inject, isDevMode } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { BandwidthChartResult, ChartResult, Condition, Database, FeatureID, GreaterOrEqual, IPScope, LessOrEqual, Netquery, NetqueryConnection, OrderBy, Pin, PossilbeValue, Query, QueryResult, SPNService, Select, Verdict } from "@safing/portmaster-api"; +import { Datasource, DynamicItemsPaginator, SelectOption } from "@safing/ui"; +import { BehaviorSubject, Observable, Subject, combineLatest, forkJoin, interval, of } from "rxjs"; +import { catchError, debounceTime, filter, map, share, skip, switchMap, take, takeUntil } from "rxjs/operators"; +import { ActionIndicatorService } from "../action-indicator"; +import { ExpertiseService } from "../expertise"; +import { objKeys } from "../utils"; +import { fadeInAnimation } from './../animations'; +import { IPScopeNames, LocalAppProfile, NetqueryHelper } from "./connection-helper.service"; +import { SfngSearchbarFields } from "./searchbar"; +import { SfngTagbarValue } from "./tag-bar"; +import { Parser } from "./textql"; +import { connectionFieldTranslation, mergeConditions } from "./utils"; +import { DefaultBandwidthChartConfig } from "./line-chart/line-chart"; +import { INTEGRATION_SERVICE } from "src/app/integration"; + +interface Suggestion extends PossilbeValue { + count: number; + selected?: boolean; +} + +interface Model { + suggestions: Suggestion[]; + searchValues: any[]; + visible: boolean | 'combinedMenu'; + menuTitle?: string; + loading: boolean; + tipupKey?: string; + virtual?: boolean; +} + +const freeTextSearchFields: (keyof Partial)[] = [ + 'domain', + 'as_owner', + 'path', + 'profile_name', + 'remote_ip' +] + +const groupByKeys: (keyof Partial)[] = [ + 'domain', + 'as_owner', + 'country', + 'direction', + 'path', + 'profile' +] + +const orderByKeys: (keyof Partial)[] = [ + 'domain', + 'as_owner', + 'country', + 'direction', + 'path', + 'started', + 'ended', + 'profile', +] + +interface LocalQueryResult extends QueryResult { + _chart: Observable | null; + _group: Observable> | null; + __profile?: LocalAppProfile; + __exitNode?: Pin; +} + +interface QuickDateSetting { + name: string; + apply: () => [Date, Date]; +} + +/** + * Netquery Viewer + * + * This component is the actual viewer component for the netquery subsystem of the Portmaster. + * It allows the user to specify connection filters in multiple different ways and allows + * to do a deep-dive into all connections seen by the Portmaster (that are still stored in + * the in-memory SQLite database of the netquery subsystem). + * + * The user is able to modify the filter query by either: + * - using the available drop-downs + * - using the searchbar which + * - supports typed searches for connection fields (i.e. country:AT domain:google.at) + * - free-text search across the list of supported "full-text" search fields (see freeTextSearchFields) + * - by shift-clicking any value that has a SfngAddToFilter directive + * - by removing values from the tag bar. + */ + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'sfng-netquery-viewer', + templateUrl: './netquery.component.html', + providers: [ + NetqueryHelper, + ], + styles: [ + ` + :host { + @apply flex flex-col gap-3 pr-3 min-h-full; + } + + .protip pre { + @apply inline-block text-xxs uppercase rounded-sm bg-gray-500 bg-opacity-25 font-mono border-gray-500 border px-0.5; + } + ` + ], + animations: [ + fadeInAnimation + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit { + /** @private Used to trigger a reload of the current filter */ + private search$ = new Subject(); + + /** @private The DestroyRef of the component, required for takeUntilDestroyed */ + private destroyRef = inject(DestroyRef); + + /** @private Used to trigger an update of all displayed values in the tag-bar. */ + private updateTagBar$ = new BehaviorSubject(undefined); + + /** @private Whether or not the next update on ActivatedRoute should be ignored */ + private skipNextRouteUpdate = false; + + /** @private Whether or not we should update the URL when performSearch() finishes */ + private skipUrlUpdate = false; + + /** @private The LOCALE_ID to format dates. */ + private localeId = inject(LOCALE_ID); + + private integration = inject(INTEGRATION_SERVICE); + + /** @private the date format for the nz-range-picker */ + dateFormat = getLocaleDateFormat(getLocaleId(this.localeId), FormatWidth.Medium) + + /** @private A list of quick-date settings for the nz-range-picker */ + quickDateSettings: QuickDateSetting[] = [ + { + name: 'Today', + apply: () => { + const now = new Date(); + return [ + new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0), + new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, -1), + ] + } + }, + { + name: 'Last 24 Hours', + apply: () => { + const now = new Date(); + return [ + new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 24, now.getMinutes(), now.getSeconds()), + now + ] + } + }, + { + name: 'Last 7 Days', + apply: () => { + const now = new Date(); + return [ + new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds()), + now, + ] + } + }, + { + name: 'Last Month', + apply: () => { + const now = new Date(); + return [ + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds()), + now, + ] + } + }, + ] + + applyQuickDateSetting(qds: QuickDateSetting) { + const [from, to] = qds.apply() + + const fromStr = formatDate(from, 'medium', this.localeId) + const toStr = formatDate(to, 'medium', this.localeId) + + this.onFieldsParsed({ + from: [fromStr], + to: [toStr] + }, true) + } + + /** @private - The paginator used for the result set */ + paginator!: DynamicItemsPaginator; + + /** @private - The total amount of connections without the filter applied */ + totalConnCount: number = 0; + + /** @private - The total amount of connections with the filter applied */ + totalResultCount: number = 0; + + /** The value of the free-text search */ + textSearch: string = ''; + + /** The date filter */ + dateFilter: Date[] = [] + + /** a list of allowed group-by keys */ + readonly allowedGroupBy = groupByKeys; + + /** a list of allowed order-by keys */ + readonly allowedOrderBy = orderByKeys; + + /** @private Whether or not we are currently loading data */ + loading = false; + + /** @private The connection chart data */ + connectionChartData: ChartResult[] = []; + + /** @private The bandwidth chart data */ + bwChartData: BandwidthChartResult[] = []; + + /** @private The configuration for the bandwidth chart */ + readonly bwChartConfig = DefaultBandwidthChartConfig; + + /** @private The list of "pro-tips" that are defined in the template. Only one pro-tip will be rendered depending on proTipIdx */ + @ViewChildren('proTip', { read: TemplateRef }) + proTips!: QueryList> + + /** @private The index of the pro-tip that is currently rendered. */ + proTipIdx = 0; + + /** @private The last time the connections were loaded */ + lastReload: Date = new Date(); + + /** @private Used to refresh the "Last reload xxx ago" message */ + lastReloadTicker = interval(2000) + .pipe( + takeUntilDestroyed(this.destroyRef), + map(() => Math.floor((new Date()).getTime() - this.lastReload.getTime()) / 1000), + share() + ) + + // whether or not the history database should be queried as well. + get useHistory() { + return this.dateFilter?.length; + } + + private get databases(): Database[] { + if (!this.useHistory) { + return [Database.Live]; + } + + return [Database.Live, Database.History]; + } + + // whether or not the current use has the history feature available. + canUseHistory$ = inject(SPNService).profile$ + .pipe( + map(profile => { + if (!profile) { + return false; + } + + return profile.current_plan?.feature_ids?.includes(FeatureID.History) || false; + }) + ); + + featureBw$ = inject(SPNService).profile$ + .pipe( + map(profile => { + if (!profile) { + return false; + } + + return profile.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || false; + }) + ); + + trackPageItem: TrackByFunction = (_, r) => { + if (this.groupByKeys?.length) { + return this.groupByKeys.map(key => r[key]).join('-') + } + return r.id! + } + + trackConnection: TrackByFunction = (_, c) => c.id + + constructor( + private netquery: Netquery, + private helper: NetqueryHelper, + private expertise: ExpertiseService, + private cdr: ChangeDetectorRef, + private actionIndicator: ActionIndicatorService, + private route: ActivatedRoute, + public router: Router, + ) { } + + @Input() + set filters(v: any | keyof this['models'] | (keyof this['models'])[]) { + v = coerceArray(v); + objKeys(this.models).forEach(key => { + // ignore any models that are marked as being shown in the combined-menu. + if (this.models[key]?.visible !== 'combinedMenu') { + this.models[key]!.visible = false; + } + }) + + v.forEach((val: any) => { + if (typeof val !== 'string') { + throw new Error("invalid value for @Input() filters") + } + + if (!this.isValidFilter(val)) { + throw new Error('invalid filter key ' + val) + } + + this.models[val]!.visible = true; + }) + } + + /** + * mergeFilter input can be used to apply an additional filter condition that cannot be modified by + * the user (like forcing a "profile" filter for the App View) + */ + @Input() + mergeFilter: Condition | null = null; + + /** The filter preset that will be used if no filter is configured otherwise */ + @Input() + filterPreset: string | null = null; + + @Output() + filterChange: EventEmitter = new EventEmitter(); + + /** @private Holds the value displayed in the tag-bar */ + tagbarValues: SfngTagbarValue[] = []; + + private updateDateRangeState() { + const values = [ + this.models.from.searchValues[0], + this.models.to.searchValues[0], + ] + + let fromValueTs = Date.parse(values[0]) + let toValueTs = Date.parse(values[1]) + + // if we failed to parse the date from a string, the user might + // just entered the timestamp in seconds + if (isNaN(fromValueTs)) { + fromValueTs = Number(values[0]) * 1000 + } + if (isNaN(toValueTs)) { + toValueTs = Number(values[1]) * 1000 + } + + const fromValid = !isNaN(fromValueTs) + const toValid = !isNaN(toValueTs) + + + let fromValue = new Date(fromValueTs) + let toValue = new Date(toValueTs); + + if (fromValid && toValid && fromValue.getTime() === toValue.getTime()) { + fromValue = new Date(fromValue.getFullYear(), fromValue.getMonth(), fromValue.getDate(), 0, 0, 0) + toValue = new Date(toValue.getFullYear(), toValue.getMonth(), toValue.getDate() + 1, 0, 0, -1) + } + + this.dateFilter = []; + + if (fromValid) { + this.dateFilter.push(fromValue) + this.models.from.searchValues = [ + formatDate(fromValue, 'medium', this.localeId) + ] + } + + if (toValid) { + if (!fromValid) { + this.dateFilter.push(new Date(2000, 0, 1)) + } + + this.dateFilter.push(toValue) + this.models.to.searchValues = [ + formatDate(toValue, 'medium', this.localeId) + ] + } + + this.cdr.markForCheck(); + } + + private getDateRangeCondition(): Condition | null { + this.updateDateRangeState() + + if (!this.dateFilter.length) { + return null + } + + const cond: GreaterOrEqual & Partial = { + $ge: Math.floor(this.dateFilter[0].getTime() / 1000), + } + + if (this.dateFilter.length >= 2) { + cond['$le'] = Math.floor(this.dateFilter[1].getTime() / 1000) + } + + return { + started: cond + } + } + + models: { [key: string]: Model } = initializeModels({ + domain: { + visible: true, + }, + as_owner: { + visible: true, + }, + country: { + visible: true, + }, + profile: { + visible: true + }, + allowed: { + visible: true, + }, + path: {}, + internal: {}, + type: {}, + encrypted: {}, + scope: { + visible: 'combinedMenu', + menuTitle: 'Network Scope', + suggestions: objKeys(IPScopeNames) + .sort() + .filter(key => key !== IPScope.Undefined) + .map(scope => { + return { + Name: IPScopeNames[scope], + Value: scope, + count: 0, + Description: '' + } + }) + }, + verdict: {}, + started: {}, + ended: {}, + profile_revision: {}, + remote_ip: {}, + remote_port: {}, + local_ip: {}, + local_port: {}, + ip_protocol: {}, + direction: { + visible: 'combinedMenu', + menuTitle: 'Direction', + suggestions: [ + { + Name: 'Inbound', + Value: 'inbound', + Description: '', + count: 0, + }, + { + Name: 'Outbound', + Value: 'outbound', + Description: '', + count: 0, + } + ] + }, + exit_node: {}, + asn: {}, + active: { + visible: 'combinedMenu', + menuTitle: 'Active', + suggestions: booleanSuggestionValues(), + }, + tunneled: { + visible: 'combinedMenu', + menuTitle: 'SPN', + suggestions: booleanSuggestionValues(), + tipupKey: 'spn' + }, + from: { + virtual: true + }, + to: { + virtual: true, + }, + }) + + /** Translations for the connection field names */ + keyTranslation = connectionFieldTranslation; + + /** A list of keys for group-by */ + groupByKeys: string[] = []; + + /** A list of keys for sorting */ + orderByKeys: string[] = []; + + ngOnInit(): void { + // Prepare the datasource that is used to initialize the DynamicItemPaginator. + // It basically has a "view" function that executes the current page query + // but with page-number and page-size applied. + // This is used by the paginator to support lazy-loading the different + // result pages. + const dataSource: Datasource = { + view: (page: number, pageSize: number) => { + const query = this.getQuery(); + query.page = page - 1; // UI starts at page 1 while the backend is 0-based + query.pageSize = pageSize; + + return this.netquery.query(query, 'netquery-viewer') + .pipe( + this.helper.attachProfile(), + this.helper.attachPins(), + map(results => { + return (results || []).map(r => { + const grpFilter: Condition = { + ...query.query, + }; + this.groupByKeys.forEach(key => { + grpFilter[key] = r[key]; + }) + + let page = { + ...r, + _chart: !!this.groupByKeys.length ? this.getGroupChart(grpFilter) : null, + _group: !!this.groupByKeys.length ? this.lazyLoadGroup(grpFilter) : null, + } + + return page; + }); + }) + ); + } + } + + // create a new paginator that will use the datasource from above. + this.paginator = new DynamicItemsPaginator(dataSource) + + // subscribe to the search observable that emits a value each time we want to perform + // a new query. + // The actual searching is debounced by second so we don't flood the Portmaster service + // with queries while the user is still configuring their filters. + this.search$ + .pipe( + debounceTime(1000), + switchMap(() => { + this.loading = true; + this.connectionChartData = []; + this.bwChartData = []; + + this.cdr.detectChanges(); + + const query = this.getQuery(); + + // we only load the overall connection chart, the total connection count for the filter result + // as well the the total connection count without any filters here. The actual results are + // loaded by the DynamicItemsPaginator using the "view" function defined above. + return forkJoin({ + query: of(query), + response: this.netquery.batch({ + totalCount: { + ...query, + select: { $count: { field: '*', as: 'totalCount' } }, + }, + + totalConnCount: { + ...query, + select: { + $count: { field: '*', as: 'totalConnCount' } + }, + } + }) + .pipe( + map(response => { + // the the correct resulsts here which depend on whether or not + // we're applying a group by. + let totalCount = 0; + if (this.groupByKeys.length === 0) { + totalCount = response.totalCount[0].totalCount; + } else { + totalCount = response.totalCount.length; + } + + return { + totalCount, + totalConnCount: response.totalConnCount, + } + }) + ), + }) + }), + ) + .subscribe(result => { + this.paginator.pageLoading$ + .pipe( + skip(1), + takeUntil(this.search$), // skip loading the chart if the user trigger a subsequent search + filter(loading => !loading), + take(1), + switchMap(() => forkJoin({ + connectionChart: this.netquery.activeConnectionChart(result.query.query!) + .pipe( + catchError(err => { + this.actionIndicator.error( + 'Internal Error', + 'Failed to load chart: ' + this.actionIndicator.getErrorMessgae(err) + ); + + return of([] as ChartResult[]); + }), + ), + bwChart: this.netquery.bandwidthChart(result.query.query!, [], 60) + })), + ) + .subscribe(chart => { + this.connectionChartData = chart.connectionChart; + this.bwChartData = chart.bwChart; + + this.cdr.markForCheck(); + }) + + // reset the paginator with the new total result count and + // open the first page. + this.paginator.reset(result.response.totalCount); + this.totalConnCount = result.response.totalConnCount[0].totalConnCount; + this.totalResultCount = result.response.totalCount; + + // update the current URL to include the new search + // query and make sure we skip the parameter-update emitted by + // router. + if (!this.skipUrlUpdate) { + this.skipNextRouteUpdate = true; + + const queryText = this.getQueryString(); + + this.filterChange.next(queryText); + + // note that since we only update the query parameters and stay on + // the current route this component will not get re-created but will + // rather receive an update on the queryParamMap (see below). + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + ...this.route.snapshot.queryParams, + q: queryText, + }, + }) + } + this.skipUrlUpdate = false; + + this.loading = false; + this.cdr.markForCheck(); + }) + + // subscribe to router updates so we apply the filter that is part of + // the current query parameter "q". + // We might ignore updates here depending on the value of "skipNextRouterUpdate". + // This is required as we keep the route parameters in sync with the current filter. + this.route.queryParamMap + .pipe( + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(params => { + if (this.skipNextRouteUpdate) { + this.skipNextRouteUpdate = false; + return; + } + + const query = params.get("q") + + if (query !== null) { + objKeys(this.models).forEach(key => { + this.models[key]!.searchValues = []; + }) + + const result = Parser.parse(query!) + + this.onFieldsParsed({ + ...result.conditions, + groupBy: result.groupBy, + orderBy: result.orderBy, + }); + this.textSearch = result.textQuery; + } + + this.skipUrlUpdate = true; + this.performSearch(); + }) + + // we might get new search values from our helper service + // in case the user "SHIFT-Clicks" a SfngAddToFilter directive. + this.helper.onFieldsAdded() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(fields => this.onFieldsParsed(fields)) + + // updateTagBar$ always emits a value when we need to update the current tag-bar values. + // This must always be done if the current search filter has been modified in either of + // the supported ways. + this.updateTagBar$ + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(() => { + const obs: Observable<{ [key: string]: (PossilbeValue & QueryResult)[] }>[] = []; + + // for the tag bar we try to show some pretty names for values that are meant to be + // internal (like the number-constants for the verdicts or using the profile name instead + // of the profile ID). Since we might need to load data from the Portmaster for this (like + // for profile names) we construct a list of observables using helper.encodeToPossibleValues + // and use the result for the tagbar. + Object.keys(this.models) + .sort() // make sure we always output values in a constant order + .forEach(modelKey => { + const values = this.models[modelKey]!.searchValues; + + if (values.length > 0) { + obs.push( + of(values.map(val => ({ + [modelKey]: val, + }))) + .pipe( + this.helper.encodeToPossibleValues(modelKey), + map(result => ({ + [modelKey]: result, + })) + ) + ) + } + }) + + if (obs.length === 0) { + return of([]); + } + + return combineLatest(obs); + }) + ) + .subscribe(tagBarValues => { + this.tagbarValues = []; + + // reset the "selected" field of each model that is shown in the "combinedMenu". + // we'll set the correct ones as "selected" again in the next step. + objKeys(this.models).forEach(key => { + if (this.models[key]?.visible === 'combinedMenu') { + this.models[key]?.suggestions.forEach(val => val.selected = false); + } + }) + + // finally construct a new list of tag-bar values and update the "selected" field of + // suggested-values for the "combinedMenu" items based on the actual search values. + tagBarValues.forEach(obj => { + objKeys(obj).forEach(key => { + if (obj[key].length > 0) { + this.tagbarValues.push({ + key: key as string, + values: obj[key], + }) + + // update the `selected` field of suggested-values for each model that is displayed in the combined-menu + const modelsKey = key as keyof NetqueryConnection; + if (this.models[modelsKey]?.visible === 'combinedMenu') + this.models[modelsKey]?.suggestions.forEach(suggestedValue => { + suggestedValue.selected = obj[key].some(val => val.Value === suggestedValue.Value); + }) + } + }) + }) + + this.cdr.markForCheck(); + }) + + // handle any filter preset + // + if (!!this.filterPreset) { + try { + const result = Parser.parse(this.filterPreset); + this.onFieldsParsed({ + ...result.conditions, + groupBy: result.groupBy, + orderBy: result.orderBy, + }); + } catch (err) { + // only log the error in dev mode as this is most likely + // just bad user input + if (isDevMode()) { + console.error(err); + } + } + } + } + + ngAfterViewInit(): void { + // once we are initialized decide which pro-tip we want to show this time... + this.proTipIdx = Math.floor(Math.random() * this.proTips.length); + } + + ngOnDestroy() { + this.paginator.clear(); + this.search$.complete(); + this.helper.dispose(); + } + + // lazyLoadGroup returns an observable that will emit a DynamicItemsPaginator once subscribed. + // This is used in "group-by" views to lazy-load the content of the group once the user + // expands it. + lazyLoadGroup(groupFilter: Condition): Observable> { + return new Observable(observer => { + this.netquery.query({ + query: groupFilter, + select: [ + { $count: { field: "*", as: "totalCount" } } + ], + orderBy: [ + { field: 'started', desc: true }, + { field: 'ended', desc: true } + ], + databases: this.databases, + }, 'netquery-viewer-load-group') + .subscribe(result => { + const paginator = new DynamicItemsPaginator({ + view: (pageNumber: number, pageSize: number) => { + return this.netquery.query({ + query: groupFilter, + orderBy: [ + { field: 'started', desc: true }, + { field: 'ended', desc: true } + ], + page: pageNumber - 1, + pageSize: pageSize, + databases: this.databases, + }, 'netquery-viewer-group-paginator') as Observable; + } + }, 25) + + paginator.reset(result[0]?.totalCount || 0) + + observer.next(paginator) + }) + }) + } + + // Returns an observable that loads the current active connection chart using the + // current page query but only for the condition of the displayed group. + getGroupChart(groupFilter: Condition): Observable { + return this.netquery.activeConnectionChart(groupFilter) + } + + // loadSuggestion loads possible values for a given connection field + // and updates the "suggestions" field of the correct models entry. + // It also uses helper.encodeToPossibleValues to make sure we show + // pretty names for otherwise "internal" values like verdict constants + // or profile IDs. + loadSuggestion(field: string): void; + loadSuggestion(field: T) { + const search = this.getQuery([field]); + + this.models[field]!.loading = !this.models[field]!.suggestions?.length; + + this.netquery.query({ + select: [ + field, + { + $count: { + field: "*", + as: "count" + }, + } + ], + query: search.query, + groupBy: [ + field, + ], + orderBy: [{ field: "count", desc: true }, { field, desc: true }], + databases: this.databases, + }, 'netquery-viewer-load-suggestions') + .pipe(this.helper.encodeToPossibleValues(field)) + .subscribe(result => { + this.models[field]!.loading = false; + + // create a set that we can use to lookup if a value + // is currently selected. + // This is needed to ensure selected values are sorted to the top. + let currentlySelected = new Set(); + this.models[field]!.searchValues.forEach( + val => currentlySelected.add(val) + ); + + this.models[field]!.suggestions = + result + .sort((a, b) => { + const hasA = currentlySelected.has(a.Value); + const hasB = currentlySelected.has(b.Value); + + if (hasA && !hasB) { + return -1; + } + if (hasB && !hasA) { + return 1; + } + + return b.count - a.count; + }) as any; + + this.cdr.markForCheck(); + }) + } + + sortByCount(a: SelectOption, b: SelectOption) { + return b.data - a.data + } + + /** @private Callback for keyboard events on the search-input */ + onFieldsParsed(fields: SfngSearchbarFields, replace = false) { + const allowedKeys = new Set(Object.keys(this.models)) + + objKeys(fields).forEach(key => { + if (key === 'groupBy') { + this.groupByKeys = (fields.groupBy || this.groupByKeys) + .filter(val => { + // an empty value is just filtered out without an error as this is the only + // way to specify "I don't want grouping" via the filter + if (val === '') { + return false; + } + + if (!allowedKeys.has(val as any)) { + this.actionIndicator.error("Invalid search query", "Column " + val + " is not allowed for groupby") + return false; + } + return true; + }) + + return; + } + + if (key === 'orderBy') { + this.orderByKeys = (fields.orderBy || this.orderByKeys) + .filter(val => { + if (!allowedKeys.has(val as any)) { + this.actionIndicator.error("Invalid search query", "Column " + val + " is not allowed for orderby") + return false; + } + return true; + }) + + return; + } + + if (!allowedKeys.has(key)) { + this.actionIndicator.error("Invalid search query", "Column " + key + " is not allowed for filtering"); + return; + } + + if (fields[key]?.length === 0 && replace) { + this.models[key].searchValues = []; + } else { + fields[key]!.forEach(val => { + // quick fix to make sure domains always end in a period. + if (key === 'domain' && typeof val === 'string' && val.length > 0 && !val.endsWith('.')) { + val = `${val}.` + } + + if (typeof val === 'object' && '$ne' in val) { + this.actionIndicator.error("NOT conditions are not yet supported") + return; + } + + // avoid duplicates + if (this.models[key]!.searchValues.includes(val)) { + return; + } + + if (!replace) { + this.models[key]!.searchValues = [ + ...this.models[key]!.searchValues, + val, + ] + } else { + this.models[key]!.searchValues = [val] + } + }) + } + + this.updateDateRangeState() + }) + + this.cdr.markForCheck(); + + this.performSearch(); + } + + /** @private Query the portmaster service for connections matching the current settings */ + performSearch() { + this.loading = true; + this.lastReload = new Date(); + this.paginator.clear() + this.search$.next(); + this.updateTagbarValues(); + } + + /** @private Returns the current query in it's string representation */ + getQueryString(): string { + let result = ''; + + objKeys(this.models).forEach(key => { + this.models[key]?.searchValues.forEach(val => { + // we use JSON.stringify here to make sure the value is + // correclty quoted. + result += `${key}:${JSON.stringify(val)} `; + }) + }) + + if (result.length > 0 && this.textSearch.length > 0) { + result += ' ' + } + + this.groupByKeys.forEach(key => { + result += `groupby:"${key}" ` + }) + this.orderByKeys.forEach(key => { + result += `orderby:"${key}" ` + }) + + if (result.length > 0 && this.textSearch.length > 0) { + result += ' ' + } + + result += `${this.textSearch}` + + return result; + } + + /** @private Copies the current query into the user clipboard */ + copyQuery() { + this.integration.writeToClipboard(this.getQueryString()) + .then(() => { + this.actionIndicator.success("Query copied to clipboard", 'Go ahead and share your query!') + }) + .catch((err) => { + this.actionIndicator.error('Failed to copy to clipboard', this.actionIndicator.getErrorMessgae(err)) + }) + } + + /** @private Clears the current query */ + clearQuery() { + objKeys(this.models).forEach(key => { + this.models[key]!.searchValues = []; + }) + this.textSearch = ''; + + this.updateTagbarValues(); + this.performSearch(); + } + + /** @private Constructs a query from the current page settings. Supports excluding certain fields from the query. */ + getQuery(excludeFields: string[] = []): Query { + let query: Condition = {} + let textSearch: Query['textSearch']; + + const dateQuery = this.getDateRangeCondition() + if (dateQuery !== null) { + query = mergeConditions(query, dateQuery) + } + + // create the query conditions for all keys on this.models + Object.keys(this.models).forEach((key: string) => { + if (excludeFields.includes(key)) { + return; + } + + if (this.models[key]!.searchValues.length > 0) { + // check if model is virtual, and if, skip adding it to the query + if (this.models[key].virtual) { + return + } + + query[key] = { + $in: this.models[key]!.searchValues, + } + } + }) + + if (this.expertise.currentLevel !== 'developer') { + query["internal"] = { + $eq: false, + } + } + + if (this.textSearch !== '') { + textSearch = { + fields: freeTextSearchFields, + value: this.textSearch + } + } + + let select: Query['select'] | undefined = undefined; + if (!!this.groupByKeys.length) { + // we always want to show the total and the number of allowed connections + // per group so we need to add those to the select part of the query + select = [ + { + $count: { + field: "*", + as: "totalCount", + }, + }, + { + $sum: { + condition: { + verdict: { + $in: [ + Verdict.Accept, + Verdict.RerouteToNs, + Verdict.RerouteToTunnel + ], + } + }, + as: "countAllowed" + } + }, + ...this.groupByKeys, + ] + } + + let normalizedQuery = mergeConditions(query, this.mergeFilter || {}) + + let orderBy: string[] | OrderBy[] = this.orderByKeys; + if (!orderBy || orderBy.length === 0) { + orderBy = [ + { + field: 'started', + desc: true, + }, + { + field: 'ended', + desc: true, + } + ] + } + + return { + select: select, + query: normalizedQuery, + groupBy: this.groupByKeys, + orderBy: orderBy, + textSearch, + databases: this.databases, + } + } + + /** @private Updates the current model form all values emited by the tag-bar. */ + onTagbarChange(tagKinds: SfngTagbarValue[]) { + objKeys(this.models).forEach(key => { + this.models[key]!.searchValues = []; + }); + + tagKinds.forEach(kind => { + const key = kind.key as keyof NetqueryConnection; + this.models[key]!.searchValues = kind.values.map(possibleValue => possibleValue.Value); + + if (this.models[key]?.visible === 'combinedMenu') + this.models[key]?.suggestions.forEach(val => { + val.selected = this.models[key]!.searchValues.find(searchValue => searchValue === val.Value) + }) + }) + + this.updateDateRangeState(); + + this.performSearch(); + } + + onDateRangeChange(event: Date[]) { + if (event.length >= 1) { + event[0] = new Date(event[0].getFullYear(), event[0].getMonth(), event[0].getDate(), 0, 0, 0) + this.onFieldsParsed({ from: [formatDate(event[0], 'medium', this.localeId)] }, true) + } else { + this.onFieldsParsed({ from: [] }, true) + } + + if (event.length >= 2) { + event[1] = new Date(event[1].getFullYear(), event[1].getMonth(), event[1].getDate() + 1, 0, 0, -1) + this.onFieldsParsed({ to: [formatDate(event[1], 'medium', this.localeId)] }, true) + } else { + this.onFieldsParsed({ to: [] }, true) + } + } + + /** Updates the {@link tagbarValues} from {@link models}*/ + private updateTagbarValues() { + this.updateTagBar$.next(); + } + + private isValidFilter(key: string): key is keyof NetqueryConnection { + return Object.keys(this.models).includes(key); + } + + useAsFilter(rec: QueryResult) { + const keys = new Set(objKeys(this.models)) + + // reset the search values + keys.forEach(key => { + this.models[key]!.searchValues = []; + }) + + objKeys(rec).forEach(key => { + if (keys.has(key as keyof NetqueryConnection)) { + this.models[key as keyof NetqueryConnection]!.searchValues = [rec[key]]; + } + }) + + // reset the group-by-keys since they don't make any sense anymore. + this.groupByKeys = []; + this.performSearch(); + } + + /** @private - used by the combined filter menu */ + toggleCombinedMenuFilter(key: string, value: Suggestion) { + const k = key as keyof NetqueryConnection; + if (value.selected) { + this.models[k]!.searchValues = this.models[k]?.searchValues.filter(val => val !== value.Value) || []; + } else { + this.models[k]!.searchValues.push(value.Value) + } + + this.updateTagbarValues(); + this.performSearch(); + } + + trackSuggestion: TrackByFunction = (_: number, s: Suggestion) => s.Name + '::' + s.Value; +} + +function initializeModels(models: { [key: string]: Partial> }): { [key: string]: Model } { + objKeys(models).forEach(key => { + models[key] = { + suggestions: [], + searchValues: [], + visible: false, + loading: false, + ...models[key], + } + }) + + return models as any; +} + +function booleanSuggestionValues(): Suggestion[] { + return [ + { + Name: 'Yes', + Value: true, + Description: '', + count: 0, + }, + { + Name: 'No', + Value: false, + Description: '', + count: 0, + }, + ] +} diff --git a/desktop/angular/src/app/shared/netquery/netquery.module.ts b/desktop/angular/src/app/shared/netquery/netquery.module.ts new file mode 100644 index 00000000..5a433666 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/netquery.module.ts @@ -0,0 +1,88 @@ +import { A11yModule } from "@angular/cdk/a11y"; +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { inject, NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { SfngAccordionModule, SfngDropDownModule, SfngPaginationModule, SfngSelectModule, SfngTipUpModule, SfngToggleSwitchModule, SfngTooltipModule } from "@safing/ui"; +import { NzDatePickerModule } from 'ng-zorro-antd/date-picker'; +import { SfngAppIconModule } from "../app-icon"; +import { CountIndicatorModule } from "../count-indicator"; +import { CountryFlagModule } from "../country-flag"; +import { ExpertiseModule } from "../expertise/expertise.module"; +import { SfngFocusModule } from "../focus"; +import { SfngMenuModule } from "../menu"; +import { CommonPipesModule } from "../pipes"; +import { SPNModule } from './../../pages/spn/spn.module'; +import { SfngNetqueryAddToFilterDirective } from "./add-to-filter"; +import { CombinedMenuPipe } from "./combined-menu.pipe"; +import { SfngNetqueryConnectionDetailsComponent } from "./connection-details"; +import { SfngNetqueryConnectionRowComponent } from "./connection-row"; +import { SfngNetqueryLineChartComponent } from "./line-chart/line-chart"; +import { SfngNetqueryViewer } from "./netquery.component"; +import { CanShowConnection, CanUseRulesPipe, ConnectionLocationPipe, CountryNamePipe, CountryNameService, IsBlockedConnectionPipe } from "./pipes"; +import { SfngNetqueryScopeLabelComponent } from "./scope-label"; +import { SfngNetquerySearchOverlayComponent } from "./search-overlay"; +import { SfngNetquerySearchbarComponent, SfngNetquerySuggestionDirective } from "./searchbar"; +import { SfngNetqueryTagbarComponent } from "./tag-bar"; +import { CircularBarChartComponent } from './circular-bar-chart/circular-bar-chart.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + CountryFlagModule, + SfngDropDownModule, + SfngSelectModule, + SfngTooltipModule, + SfngAccordionModule, + SfngMenuModule, + SfngPaginationModule, + SfngFocusModule, + SfngAppIconModule, + SfngTipUpModule, + SfngToggleSwitchModule, + A11yModule, + ExpertiseModule, + OverlayModule, + CountIndicatorModule, + FontAwesomeModule, + CommonPipesModule, + SPNModule, + NzDatePickerModule, + ], + exports: [ + SfngNetqueryViewer, + SfngNetqueryLineChartComponent, + SfngNetquerySearchOverlayComponent, + SfngNetqueryScopeLabelComponent, + CircularBarChartComponent, + ], + declarations: [ + SfngNetqueryViewer, + SfngNetqueryConnectionRowComponent, + SfngNetqueryLineChartComponent, + SfngNetqueryTagbarComponent, + SfngNetquerySearchbarComponent, + SfngNetquerySearchOverlayComponent, + SfngNetquerySuggestionDirective, + SfngNetqueryScopeLabelComponent, + SfngNetqueryConnectionDetailsComponent, + SfngNetqueryAddToFilterDirective, + ConnectionLocationPipe, + IsBlockedConnectionPipe, + CanUseRulesPipe, + CanShowConnection, + CombinedMenuPipe, + CircularBarChartComponent, + CountryNamePipe, + ], + providers: [ + CountryNameService + ] +}) +export class NetqueryModule { + private _unusedBootstrap = [ + inject(CountryNameService), // make sure country names are loaded on bootstrap + ] +} diff --git a/desktop/angular/src/app/shared/netquery/pipes/can-show.pipe.ts b/desktop/angular/src/app/shared/netquery/pipes/can-show.pipe.ts new file mode 100644 index 00000000..35f93628 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/pipes/can-show.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { ExpertiseLevel, NetqueryConnection } from "@safing/portmaster-api"; + + +@Pipe({ + name: "canShowConnection", + pure: true, +}) +export class CanShowConnection implements PipeTransform { + transform(conn: NetqueryConnection, level: ExpertiseLevel) { + if (!conn) { + return false; + } + if (level === ExpertiseLevel.Developer) { + // we show all connections for developers + return true; + } + // if we are in advanced or simple mode we should + // hide internal connections. + return !conn.internal; + } +} diff --git a/desktop/angular/src/app/shared/netquery/pipes/can-use-rules.pipe.ts b/desktop/angular/src/app/shared/netquery/pipes/can-use-rules.pipe.ts new file mode 100644 index 00000000..d4b5d4d3 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/pipes/can-use-rules.pipe.ts @@ -0,0 +1,32 @@ + +// the following settings are stronger than rules +// and cannot be "fixed" by creating a new allow/deny + +import { Pipe, PipeTransform } from "@angular/core"; +import { IsDenied, NetqueryConnection } from "@safing/portmaster-api"; + +// rule. +let optionKeys = new Set([ + "filter/blockInternet", + "filter/blockLAN", + "filter/blockLocal", + "filter/blockP2P", + "filter/blockInbound" +]) + +@Pipe({ + name: "canUseRules", + pure: true, +}) +export class CanUseRulesPipe implements PipeTransform { + transform(conn: NetqueryConnection): boolean { + if (!conn) { + return false; + } + if (!!conn.extra_data?.reason?.OptionKey && IsDenied(conn.verdict)) { + return !optionKeys.has(conn.extra_data.reason.OptionKey); + } + return true; + } +} + diff --git a/desktop/angular/src/app/shared/netquery/pipes/country-name.pipe.ts b/desktop/angular/src/app/shared/netquery/pipes/country-name.pipe.ts new file mode 100644 index 00000000..93e6bc61 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/pipes/country-name.pipe.ts @@ -0,0 +1,59 @@ +import { HttpClient } from '@angular/common/http'; +import { Pipe, PipeTransform, Injectable, inject } from '@angular/core'; +import { GeoCoordinates, SPNService } from '@safing/portmaster-api'; +import { environment } from 'src/environments/environment'; +import { ActionIndicatorService } from '../../action-indicator'; +import { objKeys } from '../../utils'; + +export interface CountryListResponse { + [countryKey: string]: { + Code: string; + Name: string; + Center: GeoCoordinates; + Continent: { + Code: string; + Region: string; + Name: string; + } + } +} + +@Injectable() +export class CountryNameService { + private readonly spn = inject(SPNService); + private readonly http = inject(HttpClient); + private readonly uai = inject(ActionIndicatorService); + + private map: Map = new Map(); + + constructor() { + this.http.get(`${environment.httpAPI}/v1/intel/geoip/countries`) + .subscribe({ + next: response => { + objKeys(response) + .forEach(key => { + this.map.set(key as string, response[key].Name); + }); + }, + error: err => { + this.uai.error('Failed to fetch country data', this.uai.getErrorMessage(err)); + } + }) + } + + resolveName(code: string): string { + return this.map.get(code) || ''; + } +} + +@Pipe({ + name: 'countryName', + pure: true, +}) +export class CountryNamePipe implements PipeTransform { + private countryService = inject(CountryNameService); + + transform(countryCode: string) { + return this.countryService.resolveName(countryCode); + } +} diff --git a/desktop/angular/src/app/shared/netquery/pipes/index.ts b/desktop/angular/src/app/shared/netquery/pipes/index.ts new file mode 100644 index 00000000..9b429e59 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/pipes/index.ts @@ -0,0 +1,5 @@ +export * from './location.pipe'; +export * from './can-show.pipe'; +export * from './can-use-rules.pipe'; +export * from './is-blocked.pipe'; +export * from './country-name.pipe'; diff --git a/desktop/angular/src/app/shared/netquery/pipes/is-blocked.pipe.ts b/desktop/angular/src/app/shared/netquery/pipes/is-blocked.pipe.ts new file mode 100644 index 00000000..fcb6dc0d --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/pipes/is-blocked.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { IsDenied, NetqueryConnection } from '@safing/portmaster-api'; + +@Pipe({ + name: "isBlocked", + pure: true +}) +export class IsBlockedConnectionPipe implements PipeTransform { + transform(conn: NetqueryConnection): boolean { + return IsDenied(conn?.verdict); + } +} diff --git a/desktop/angular/src/app/shared/netquery/pipes/location.pipe.ts b/desktop/angular/src/app/shared/netquery/pipes/location.pipe.ts new file mode 100644 index 00000000..522ed86a --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/pipes/location.pipe.ts @@ -0,0 +1,33 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { IsGlobalScope, IsLANScope, IsLocalhost, NetqueryConnection } from '@safing/portmaster-api'; + +@Pipe({ + name: 'connectionLocation', + pure: true, +}) +export class ConnectionLocationPipe implements PipeTransform { + transform(conn: NetqueryConnection): string { + if (conn.type === 'dns') { + return ''; + } + if (!!conn.country) { + return conn.country; + } + + const scope = conn.scope; + + if (IsGlobalScope(scope)) { + return 'Internet' + } + + if (IsLANScope(scope)) { + return 'LAN'; + } + + if (IsLocalhost(scope)) { + return 'Device' + } + + return ''; + } +} diff --git a/desktop/angular/src/app/shared/netquery/scope-label/index.ts b/desktop/angular/src/app/shared/netquery/scope-label/index.ts new file mode 100644 index 00000000..8f481940 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/scope-label/index.ts @@ -0,0 +1 @@ +export * from './scope-label'; diff --git a/desktop/angular/src/app/shared/netquery/scope-label/scope-label.html b/desktop/angular/src/app/shared/netquery/scope-label/scope-label.html new file mode 100644 index 00000000..0df11b57 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/scope-label/scope-label.html @@ -0,0 +1,8 @@ + + {{subdomain}}. + {{domain}} + + + {{ scopeTranslation[scope || ''] || 'N/A' }} + diff --git a/desktop/angular/src/app/shared/netquery/scope-label/scope-label.ts b/desktop/angular/src/app/shared/netquery/scope-label/scope-label.ts new file mode 100644 index 00000000..8bb64c83 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/scope-label/scope-label.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ScopeTranslation } from '@safing/portmaster-api'; +import { parseDomain } from '../../utils'; + +@Component({ + selector: 'sfng-netquery-scope-label', + templateUrl: 'scope-label.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SfngNetqueryScopeLabelComponent implements OnChanges { + readonly scopeTranslation = ScopeTranslation; + + @Input() + scope?: string = '' + + @Input() + set leftRightFix(v: any) { + console.warn("deprecated @Input usage") + } + get leftRightFix() { return false } + + domain: string = ''; + subdomain: string = ''; + + ngOnChanges(change: SimpleChanges) { + if (!!change['scope']) { + //this.label = change.label.currentValue; + const result = parseDomain(change.scope.currentValue || '') + + this.domain = result?.domain || ''; + this.subdomain = result?.subdomain || ''; + } + } +} diff --git a/desktop/angular/src/app/shared/netquery/search-overlay/index.ts b/desktop/angular/src/app/shared/netquery/search-overlay/index.ts new file mode 100644 index 00000000..ffad6a32 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/search-overlay/index.ts @@ -0,0 +1 @@ +export * from './search-overlay'; diff --git a/desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.html b/desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.html new file mode 100644 index 00000000..49eaa84b --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.html @@ -0,0 +1,2 @@ + diff --git a/desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.ts b/desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.ts new file mode 100644 index 00000000..eaae1a08 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.ts @@ -0,0 +1,81 @@ +import { ChangeDetectionStrategy, Component, Inject } from "@angular/core"; +import { Router } from "@angular/router"; +import { SfngDialogRef, SFNG_DIALOG_REF } from "@safing/ui"; +import { objKeys } from "../../utils"; +import { NetqueryHelper } from "../connection-helper.service"; +import { SfngSearchbarFields } from "../searchbar"; +import { connectionFieldTranslation } from "../utils"; + +@Component({ + selector: 'sfng-netquery-search-overlay', + templateUrl: './search-overlay.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + NetqueryHelper, + ], + styles: [ + ` + :host { + @apply block; + width: 700px; + } + + ::ng-deep sfng-netquery-search-overlay sfng-netquery-searchbar input { + border: 1px solid theme("colors.gray.200") !important; + } + ` + ] +}) +export class SfngNetquerySearchOverlayComponent { + keyTranslation = connectionFieldTranslation; + + textSearch = ''; + + fields: SfngSearchbarFields = {}; + + constructor( + @Inject(SFNG_DIALOG_REF) private dialogRef: SfngDialogRef, + private router: Router, + ) { } + + performSearch() { + let query = ""; + const fields = objKeys(this.fields) + + // if there's only one profile key directly navigate the user to the app view + if (fields.length === 1 && fields[0] === 'profile' && this.fields.profile!.length === 1) { + let profileName: string = this.fields.profile![0] || ''; + if (!profileName.includes("/")) { + profileName = "local/" + profileName + } + this.router.navigate(['/app/' + profileName || '']) + this.dialogRef.close(); + return; + } + + fields.forEach(field => { + this.fields[field]?.forEach(value => { + query += `${field}:${JSON.stringify(value)} ` + }) + }) + + if (query !== '' && this.textSearch !== '') { + query += " " + } + query += this.textSearch; + + this.router.navigate(['/monitor'], { + queryParams: { + q: query, + } + }) + + this.dialogRef.close(); + } + + onFieldsParsed(fields: SfngSearchbarFields) { + objKeys(fields).forEach(field => { + this.fields[field] = [...(this.fields[field] || []), ...(fields[field] || [])]; + }) + } +} diff --git a/desktop/angular/src/app/shared/netquery/searchbar/index.ts b/desktop/angular/src/app/shared/netquery/searchbar/index.ts new file mode 100644 index 00000000..2520d4d6 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/searchbar/index.ts @@ -0,0 +1 @@ +export * from './searchbar'; diff --git a/desktop/angular/src/app/shared/netquery/searchbar/searchbar.html b/desktop/angular/src/app/shared/netquery/searchbar/searchbar.html new file mode 100644 index 00000000..ef2dedf4 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/searchbar/searchbar.html @@ -0,0 +1,76 @@ +
+
+ + + +
+ +
+ + + +
+ +
+
+ + +
    +
  • + Full-Text Search: {{ textSearch }} +
  • +
+ +
+ + +
+

+ Filter by {{ labels[sug.field] || sug.field }} +

+
    +
  • + {{ val.display || (val.value === '' ? 'N/A' : val.value) }} + #{{ val.count }} connections +
  • +
+
+
+ +
+ + + + + + Loading suggestions ... +
+ + + + There are no suggestions for your query. Press +
Enter
+ to + perform a full text search. +
+
+
+
diff --git a/desktop/angular/src/app/shared/netquery/searchbar/searchbar.ts b/desktop/angular/src/app/shared/netquery/searchbar/searchbar.ts new file mode 100644 index 00000000..46a42f51 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/searchbar/searchbar.ts @@ -0,0 +1,437 @@ +import { ListKeyManager } from "@angular/cdk/a11y"; +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { CdkOverlayOrigin } from "@angular/cdk/overlay"; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Directive, ElementRef, EventEmitter, forwardRef, HostBinding, HostListener, inject, Input, OnDestroy, OnInit, Output, QueryList, TrackByFunction, ViewChild, ViewChildren } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { Condition, ExpertiseLevel, Netquery, NetqueryConnection } from "@safing/portmaster-api"; +import { SfngDropdownComponent } from "@safing/ui"; +import { combineLatest, Observable, of, Subject } from "rxjs"; +import { catchError, debounceTime, map, switchMap } from "rxjs/operators"; +import { fadeInAnimation, fadeInListAnimation } from "../../animations"; +import { ExpertiseService } from "../../expertise"; +import { objKeys } from "../../utils"; +import { NetqueryHelper } from "../connection-helper.service"; +import { Parser, ParseResult } from "../textql"; + +export type SfngSearchbarFields = { + [key in keyof Partial]: any[]; +} & { + groupBy?: string[]; + orderBy?: string[]; + from?: string[]; + to?: string[]; +} + +export type SfngSearchbarSuggestionValue = { + value: NetqueryConnection[K]; + display: string; + count: number; +} + +export type SfngSearchbarSuggestion = { + start?: number; + field: K | '_textsearch'; + values: SfngSearchbarSuggestionValue[]; +} + +@Directive({ + selector: '[sfngNetquerySuggestion]', + exportAs: 'sfngNetquerySuggestion' +}) +export class SfngNetquerySuggestionDirective { + constructor() { } + + @Input() + sfngSuggestion?: SfngSearchbarSuggestion; + + @Input() + sfngNetquerySuggestion?: SfngSearchbarSuggestionValue | string; + + set active(v: any) { + this._active = coerceBooleanProperty(v); + } + get active() { + return this._active; + } + private _active: boolean = false; + + getLabel(): string { + if (typeof this.sfngNetquerySuggestion === 'string') { + return this.sfngNetquerySuggestion; + } + return '' + this.sfngNetquerySuggestion?.value; + } +} + +@Component({ + selector: 'sfng-netquery-searchbar', + templateUrl: './searchbar.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeInListAnimation + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SfngNetquerySearchbarComponent), + multi: true, + } + ] +}) +export class SfngNetquerySearchbarComponent implements ControlValueAccessor, OnInit, OnDestroy, AfterViewInit { + private loadSuggestions$ = new Subject(); + private triggerDropdownClose$ = new Subject(); + private keyManager!: ListKeyManager>; + private destroyRef = inject(DestroyRef); + + /** Whether or not we are currently loading suggestions */ + loading = false; + + @ViewChild(CdkOverlayOrigin, { static: true }) + searchBoxOverlayOrigin!: CdkOverlayOrigin; + + @ViewChild(SfngDropdownComponent) + suggestionDropDown?: SfngDropdownComponent; + + @ViewChild('searchBar', { static: true, read: ElementRef }) + searchBar!: ElementRef; + + @ViewChildren(SfngNetquerySuggestionDirective) + suggestionValues!: QueryList>; + + @Output() + fieldsParsed = new EventEmitter(); + + @Input() + labels: { [key: string]: string } = {} + + /** Controls whether or not suggestions are shown as a drop-down or inline */ + @Input() + mode: 'inline' | 'default' = 'default'; + + suggestions: SfngSearchbarSuggestion[] = []; + + textSearch = ''; + + @HostListener('focus') + onFocus() { + // move focus forward to the input element + this.searchBar.nativeElement.focus(); + } + + @Input() + @HostBinding('tabindex') + tabindex = 0; + + writeValue(val: string): void { + if (typeof val === 'string') { + const result = Parser.parse(val); + this.textSearch = result.textQuery; + } else { + this.textSearch = ''; + } + this.cdr.markForCheck(); + } + + _onChange: (val: string) => void = () => { } + registerOnChange(fn: any): void { + this._onChange = fn; + } + + _onTouched: () => void = () => { } + registerOnTouched(fn: any): void { + this._onTouched = fn + } + + ngAfterViewInit(): void { + this.keyManager = new ListKeyManager(this.suggestionValues) + .withVerticalOrientation() + .withTypeAhead() + .withHomeAndEnd() + .withWrap(); + + this.keyManager.change + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(idx => { + if (!this.suggestionValues.length) { + return + } + + this.suggestionValues.forEach(val => val.active = false); + this.suggestionValues.get(idx)!.active = true; + this.cdr.markForCheck(); + }); + } + + ngOnInit(): void { + this.loadSuggestions$ + .pipe( + debounceTime(500), + switchMap(() => { + let fields: (keyof NetqueryConnection)[] = [ + 'profile', + 'domain', + 'as_owner', + 'remote_ip', + ]; + let limit = 3; + + const parser = new Parser(this.textSearch); + const parseResult = parser.process(); + + const queries: Observable>[] = []; + const queryKeys: (keyof Partial)[] = []; + + // FIXME(ppacher): confirm .type is an actually allowed field + if (!!parser.lastUnterminatedCondition) { + fields = [parser.lastUnterminatedCondition.type as keyof NetqueryConnection]; + limit = 0; + } + + fields.forEach(field => { + let queryField = field; + + // if we are searching the profiles we use the profile name + // rather than the profile_id for searching. + if (field === 'profile') { + queryField = 'profile_name'; + } + + const query: Condition = { + [queryField]: { + $like: `%${!!parser.lastUnterminatedCondition ? parser.lastUnterminatedCondition.value : parseResult.textQuery}%` + }, + } + + // hide internal connections if the user is not a developer + if (this.expertiseService.currentLevel !== ExpertiseLevel.Developer) { + query.internal = { + $eq: false + } + } + + const obs = this.netquery.query({ + select: [ + field, + { + $count: { + field: "*", + as: "count" + }, + } + ], + query: query, + groupBy: [ + field, + ], + page: 0, + pageSize: limit, + orderBy: [{ field: "count", desc: true }] + }, 'netquery-searchbar-get-counts') + .pipe( + this.helper.encodeToPossibleValues(field), + map(results => { + let val: SfngSearchbarSuggestion = { + field: field, + values: [], + start: parser.lastUnterminatedCondition ? parser.lastUnterminatedCondition.start : undefined, + } + + results.forEach(res => { + val.values.push({ + value: res.Value, + display: res.Name, + count: res.count, + }) + }) + + return val; + }), + catchError(err => { + console.error(err); + + return of({ + field: field, + values: [], + }) + }) + ) + + queries.push(obs) + queryKeys.push(field) + }) + + return combineLatest(queries) + }), + ) + .subscribe(result => { + this.loading = false; + + this.suggestions = result + .filter((sug: SfngSearchbarSuggestion) => sug.values?.length > 0) + + this.keyManager.setActiveItem(0); + + this.cdr.markForCheck(); + }) + + this.triggerDropdownClose$ + .pipe(debounceTime(100)) + .subscribe(shouldClose => { + if (shouldClose) { + this.suggestionDropDown?.close(); + } + }) + + if (this.mode === 'inline') { + this.loadSuggestions(); + } + } + + ngOnDestroy(): void { + this.loadSuggestions$.complete(); + this.triggerDropdownClose$.complete(); + } + + cancelDropdownClose() { + this.triggerDropdownClose$.next(false); + } + + onSearchModelChange(value: string) { + if (value.length >= 3 || this.mode === 'inline') { + this.loadSuggestions(); + } else if (this.suggestionDropDown?.isOpen) { + // close the suggestion dropdown if the search input contains less than + // 3 characters and we're currently showing the dropdown + this.suggestionDropDown?.close(); + } + } + + /** @private Callback for keyboard events on the search-input */ + onSearchKeyDown(event: KeyboardEvent) { + if (event.key === ' ' && event.ctrlKey) { + this.loadSuggestions(); + event.preventDefault(); + event.stopPropagation() + return; + } + + if (event.key === 'Enter') { + + const selectedSuggestion = this.suggestionValues.toArray().findIndex(val => val.active); + if (selectedSuggestion > 0) { // we must skip 0 here as well as that's the dummy element + const sug = this.suggestionValues.get(selectedSuggestion); + this.applySuggestion(sug?.sfngSuggestion?.field, sug?.sfngNetquerySuggestion, event, sug?.sfngSuggestion?.start) + + return; + } + + this.suggestionDropDown?.close(); + this.parseAndEmit(); + this.cdr.markForCheck(); + + return; + } + + this.keyManager.onKeydown(event); + } + + onFocusLost(event: FocusEvent) { + this._onTouched(); + } + + private parseAndEmit() { + const result = Parser.parse(this.textSearch); + this.textSearch = result.textQuery; + + const keys = objKeys(result.conditions) + const meta = { + groupBy: result.groupBy || undefined, + orderBy: result.orderBy || undefined, + } + if (keys.length > 0 || meta.groupBy?.length || meta.orderBy?.length) { + let updatedConditions: ParseResult['conditions'] = {}; + keys.forEach(key => { + updatedConditions[key] = this.helper.decodePrettyValues(key as keyof NetqueryConnection, result.conditions[key]) + }) + this.fieldsParsed.next({ ...updatedConditions, ...meta }); + } + + this._onChange(this.textSearch); + } + + applySuggestion(field: keyof NetqueryConnection | '_textsearch', val: any, event: { shiftKey: boolean }, start?: number) { + // this is a full-text search so just emit the value, close the dropdown and we're done + if (field === '_textsearch') { + this._onChange(this.textSearch); + this.suggestionDropDown?.close(); + + return + } + + if (start !== undefined) { + this.textSearch = this.textSearch.slice(0, start) + } else if (!event.shiftKey) { + this.textSearch = ''; + } else { + // the user pressed shift-key and used free-text search so we remove + // the remaining part + const parseRes = Parser.parse(this.textSearch); + let query = ""; + objKeys(parseRes.conditions).forEach(field => { + parseRes.conditions[field]?.forEach(value => { + query += `${field}:${JSON.stringify(value)} ` + }) + }) + this.textSearch = query; + } + + if (event.shiftKey) { + const textqlVal = `${field}:${JSON.stringify(val)}` + if (!this.textSearch.includes(textqlVal)) { + if (this.textSearch !== '') { + this.textSearch += " " + } + this.textSearch += textqlVal + " " + this.triggerDropdownClose$.next(false) + // load new suggestions based on the new input + this.loadSuggestions(); + } + + return; + } + + // directly emit the new value and reset the text search + this.fieldsParsed.next({ + [field]: [val] + }) + + // parse and emit the current search field but without the suggestion value + this.parseAndEmit(); + + this.suggestionDropDown?.close(); + + this.cdr.markForCheck(); + } + + resetKeyboardSelection() { + this.keyManager.setActiveItem(0); + } + + loadSuggestions() { + this.loading = true; + this.loadSuggestions$.next(); + this.suggestionDropDown?.show(this.searchBoxOverlayOrigin) + } + + trackSuggestion: TrackByFunction> = (_: number, val: SfngSearchbarSuggestion) => val.field; + + constructor( + private cdr: ChangeDetectorRef, + private expertiseService: ExpertiseService, + private netquery: Netquery, + private helper: NetqueryHelper, + ) { } +} diff --git a/desktop/angular/src/app/shared/netquery/tag-bar/index.ts b/desktop/angular/src/app/shared/netquery/tag-bar/index.ts new file mode 100644 index 00000000..3439acb3 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/tag-bar/index.ts @@ -0,0 +1 @@ +export * from './tag-bar'; diff --git a/desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.html b/desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.html new file mode 100644 index 00000000..f1161e58 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.html @@ -0,0 +1,26 @@ +
+ +
+ + + {{labels[cat.key] || cat.key}}: + + + + + {{ val.Name || (val.Value === '' ? 'N/A' : val) }} + + + + + + + + +
+
+
diff --git a/desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.ts b/desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.ts new file mode 100644 index 00000000..bbff7417 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.ts @@ -0,0 +1,136 @@ +import { coerceBooleanProperty, coerceCssPixelValue } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostBinding, Input } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { PossilbeValue } from '@safing/portmaster-api'; +import { fadeInListAnimation } from '../../animations'; +import { NetqueryHelper } from '../connection-helper.service'; + +export interface SfngTagbarValue { + key: string; + values: PossilbeValue[]; +} + +@Component({ + selector: 'sfng-netquery-tagbar', + templateUrl: 'tag-bar.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + @apply flex flex-row gap-3 w-auto items-center text-xxs flex-wrap; + } + ` + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SfngNetqueryTagbarComponent), + multi: true + } + ], + animations: [ + fadeInListAnimation + ] +}) +export class SfngNetqueryTagbarComponent implements ControlValueAccessor { + @HostBinding('@fadeInList') + get itemsLength() { + return this.values?.length || 0; + } + + /** @private the current tag bar values */ + values: SfngTagbarValue[] = []; + + /** Whether or not the user can interact with the component */ + @Input() + set disabled(v: any) { + this.setDisabledState(v) + } + get disabled() { + return this._disabled; + } + private _disabled = false; + + /** Translations for the value keys */ + @Input() + labels: { [key: string]: string } = {} + + /** The maximum width of the tag text before being truncated using left-side ellipsis */ + @Input() + set maxTagWidth(width: any) { + this._maxTagWidth = coerceCssPixelValue(width) + } + get maxTagWidth() { + return this._maxTagWidth + } + private _maxTagWidth: string = '8rem' + + /** @private A {@link TrackByFunction} for {@link SfngTagbarValue} */ + trackValue(_: number, vl: SfngTagbarValue) { + return vl.key; + } + + /** Implements the {@link ControlValueAccessor} */ + writeValue(obj: SfngTagbarValue[]): void { + this.values = obj; + this.cdr.markForCheck(); + } + + /** Implements the {@link ControlValueAccessor} */ + registerOnChange(fn: any): void { + this._onChange = fn; + } + + /** @private - callback registered via registerOnChange */ + _onChange: (val: SfngTagbarValue[]) => void = () => { } + + /** Implements the {@link ControlValueAccessor} */ + registerOnTouched(fn: any): void { + this._onTouched = fn + } + + /** @private - callback registered via registerOnTouched */ + _onTouched: () => void = () => { } + + /** Implements the {@link ControlValueAccessor} */ + setDisabledState(v: any) { + this._disabled = coerceBooleanProperty(v) + this.cdr.markForCheck(); + } + + /** + * remove removes the value at index from the {@link SfngTagbarValue} + * that matches key. + */ + remove(key: string, index: number) { + if (this.disabled) { + return; + } + + console.log(this.values); + + let cpy: SfngTagbarValue[] = []; + + this.values.forEach(val => { + if (val.key === key) { + val.values = [...val.values]; + val.values.splice(index, 1) + } + cpy.push({ + ...val, + }) + }); + + this.values = cpy; + + console.log(this.values); + + this._onChange(this.values); + this.cdr.markForCheck(); + } + + constructor( + private cdr: ChangeDetectorRef, + private helper: NetqueryHelper, + ) { } +} diff --git a/desktop/angular/src/app/shared/netquery/textql/helper.ts b/desktop/angular/src/app/shared/netquery/textql/helper.ts new file mode 100644 index 00000000..8f523aaa --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/textql/helper.ts @@ -0,0 +1,21 @@ +import { Token, TokenType } from "./token"; + +export function isValueToken(tok: Token): tok is Token { + return [TokenType.STRING, TokenType.BOOL, TokenType.NUMBER].includes(tok.type) +} + +export function isDigit(x: string): boolean { + return /[0-9]+/.test(x); +} + +export function isWhitespace(ch: string): boolean { + return /\s/.test(ch) +} + +export function isLetter(ch: string): boolean { + return new RegExp('[\/a-zA-Z0-9\._-]').test(ch) +} + +export function isIdentChar(ch: string): boolean { + return /[a-zA-Z_]/.test(ch); +} diff --git a/desktop/angular/src/app/shared/netquery/textql/index.ts b/desktop/angular/src/app/shared/netquery/textql/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/textql/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/desktop/angular/src/app/shared/netquery/textql/input.ts b/desktop/angular/src/app/shared/netquery/textql/input.ts new file mode 100644 index 00000000..4180d193 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/textql/input.ts @@ -0,0 +1,41 @@ +/** Input stream returns one character at a time */ +export class InputStream { + private _pos: number = 0; + private _line: number = 0; + + constructor(private _input: string) { } + + /** Returns the next character and removes it from the stream */ + next(): string | null { + const ch = this._input.charAt(this._pos++); + return ch; + } + + get pos() { + return this._pos; + } + + /** Revert moves the current stream position back by `num` characters */ + revert(num: number) { + this._pos -= num; + } + + /** Returns the next character in the stream but does not remove it */ + peek(): string { + return this._input.charAt(this._pos); + } + + /** Returns true if we reached the end of the stream */ + eof(): boolean { + return this.peek() == ''; + } + + get left(): string { + return this._input.slice(this._pos) + } + + /** Throws an error with the current line and column */ + croak(msg: string): never { + throw new Error(`${msg} at ${this._line}:${this.pos}`); + } +} diff --git a/desktop/angular/src/app/shared/netquery/textql/lexer.ts b/desktop/angular/src/app/shared/netquery/textql/lexer.ts new file mode 100644 index 00000000..a3f2fe93 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/textql/lexer.ts @@ -0,0 +1,254 @@ +import { isDigit, isIdentChar, isLetter, isWhitespace } from "./helper"; +import { InputStream } from "./input"; +import { Token, TokenType } from "./token"; + +export class Lexer { + private _current: Token | null = null; + private _input: InputStream; + + constructor(input: string) { + this._input = new InputStream(input); + } + + /** peek returns the token at the current position in input. */ + public peek(): Token | null { + return this._current || (this._current = this.readNextToken()); + } + + /** next returns either the current token in input or reads the next one */ + public next(): Token | null { + let tok = this._current; + this._current = null; + return tok || this.readNextToken(); + } + + /** eof returns true if the lexer reached the end of the input stream */ + public eof(): boolean { + return this.peek() === null; + } + + /** croak throws and error message at the current position in the input stream */ + public croak(msg: string): never { + return this._input.croak(`${msg}. Current token is "${!!this.peek() ? this.peek()!.literal : null}"`); + } + + /** consumes the input stream as long as predicate returns true */ + private readWhile(predicate: (ch: string) => boolean): string { + let str = ''; + while (!this._input.eof() && predicate(this._input.peek())) { + str += this._input.next(); + } + + return str; + } + + /** reads a number token */ + private readNumber(): Token | null { + const start = this._input.pos; + + let has_dot = false; + let number = this.readWhile((ch: string) => { + if (ch === '.') { + if (has_dot) { + return false; + } + + has_dot = true; + return true; + } + return isDigit(ch); + }); + + if (!this._input.eof() && !isWhitespace(this._input.peek())) { + this._input.revert(number.length); + + return null; + } + + return { + type: TokenType.NUMBER, + literal: number, + value: has_dot ? parseFloat(number) : parseInt(number), + start + } + } + + private readIdent(): Token { + const start = this._input.pos; + + const id = this.readWhile(ch => isIdentChar(ch)); + if (id === 'true' || id === 'yes') { + return { + type: TokenType.BOOL, + literal: id, + value: true, + start + } + } + if (id === 'false' || id === 'no') { + return { + type: TokenType.BOOL, + literal: id, + value: false, + start + } + } + if (id === 'groupby') { + return { + type: TokenType.GROUPBY, + literal: id, + value: id, + start + } + } + if (id === 'orderby') { + return { + type: TokenType.ORDERBY, + literal: id, + value: id, + start + } + } + + return { + type: TokenType.IDENT, + literal: id, + value: id, + start + }; + } + + private readEscaped(end: string | RegExp, skipStart: boolean): string { + let escaped = false; + let str = ''; + + if (skipStart) { + this._input.next(); + } + + while (!this._input.eof()) { + let ch = this._input.next()!; + if (escaped) { + str += ch; + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if ((typeof end === 'string' && ch === end) || (end instanceof RegExp && end.test(ch))) { + break; + } else { + str += ch; + } + } + return str; + } + + private readString(quote: string | RegExp, skipStart: boolean): Token { + const start = this._input.pos; + const value = this.readEscaped(quote, skipStart) + return { + type: TokenType.STRING, + literal: value, + value: value, + start + } + } + + private readWhitespace(): Token { + const start = this._input.pos; + const value = this.readWhile(ch => isWhitespace(ch)); + return { + type: TokenType.WHITESPACE, + literal: value, + value: value, + start, + } + } + + private readNextToken(): Token | null { + const start = this._input.pos; + const ch = this._input.peek(); + if (ch === '') { + return null; + } + + if (isWhitespace(ch)) { + return this.readWhitespace() + } + + if (ch === '"') { + return this.readString('"', true); + } + + if (ch === '\'') { + return this.readString('\'', true); + } + + if (isDigit(ch)) { + const number = this.readNumber(); + if (number !== null) { + return number; + } + } + + if (ch === ':') { + this._input.next(); + return { + type: TokenType.COLON, + value: ':', + literal: ':', + start + } + } + + if (ch === '!') { + this._input.next(); + return { + type: TokenType.NOT, + value: '!', + literal: '!', + start + } + } + + if (isIdentChar(ch)) { + const ident = this.readIdent(); + + const next = this._input.peek(); + if (!this._input.eof() && (!isWhitespace(next) && next !== ':')) { + + // identifiers should always end in a colon or with a whitespace. + // if neither is the case we are in the middle of a token and are + // likely parsing a string without quotes. + this._input.revert(ident.literal.length); + + // read the string and revert by one as we terminate the string + // at the next WHITESPACE token + const tok = this.readString(new RegExp('\\s'), false) + this.revertWhitespace(); + + return tok; + } + + return ident; + } + + if (isLetter(ch)) { + const tok = this.readString(new RegExp('\\s'), false) + // read the string and revert by one as we terminate the string + // at the next WHITESPACE token + this.revertWhitespace(); + + return tok + } + + // Failed to handle the input character + return this._input.croak(`Can't handle character: ${ch}`); + } + + private revertWhitespace() { + this._input.revert(1) + if (!isWhitespace(this._input.peek())) { + this._input.next(); + } + } +} diff --git a/desktop/angular/src/app/shared/netquery/textql/parser.ts b/desktop/angular/src/app/shared/netquery/textql/parser.ts new file mode 100644 index 00000000..5cf492f7 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/textql/parser.ts @@ -0,0 +1,204 @@ +import { isDevMode } from '@angular/core'; +import { isValueToken, isWhitespace } from './helper'; +import { Lexer } from './lexer'; +import { Token, TokenType } from './token'; + + +export interface ParseResult { + conditions: { + [key: string]: (any | { $ne: any })[]; + }; + textQuery: string; + groupBy?: string[]; + orderBy?: string[]; +} + +export class Parser { + /** The underlying lexer used to tokenize the input */ + private lexer: Lexer; + + /** Holds the parsed conditions */ + private conditions: { + [key: string]: any[]; + } = {}; + + /** The last condition that has not yet been terminated. Used for scope-based suggestions */ + private _lastUnterminatedCondition: { + start: number; + type: string; + value: any; + } | null = null; + + /** A list of remaining strings/identifiers that are not part of a condition */ + private remaining: string[] = []; + + /** Returns the last condition that has not yet been terminated. */ + get lastUnterminatedCondition() { + return this._lastUnterminatedCondition; + } + + constructor(input: string) { + this.lexer = new Lexer(input); + } + + static aliases: { [key: string]: string } = { + 'provider': 'as_owner', + 'app': 'profile', + 'ip': 'remote_ip', + 'port': 'remote_port' + } + + /** parse is a shortcut for new Parser(input).process() */ + static parse(input: string): ParseResult { + return new Parser(input).process(); + } + + /** Process the whole input stream and return the parsed result */ + process(): ParseResult { + let lastIdent: Token | null = null; + let hasColon = false; + let not = false; + let groupBy: string[] = []; + let orderBy: string[] = []; + + while (true) { + const tok = this.lexer.next() + if (tok === null) { + break; + } + + if (isDevMode()) { + console.log(tok) + } + + // if we find a whitespace token we count it as a termination character + // for the last unterminated condition. + if (tok.type === TokenType.WHITESPACE) { + this._lastUnterminatedCondition = null; + } + + // Since we allow the user to enter values without quotes the + // lexer might wrongly declare a "string value" as an IDENT. + // If we have the pattern we re-classify + // the last IDENT as a STRING value + if (!!lastIdent && hasColon && tok.type === TokenType.IDENT) { + tok.type = TokenType.STRING; + } + + if (tok.type === TokenType.IDENT || tok.type === TokenType.GROUPBY || tok.type === TokenType.ORDERBY) { + // if we had an IDENT token before and got a new one now the + // previous one is pushed to the remaining list + if (!!lastIdent) { + this._lastUnterminatedCondition = null; + this.remaining.push(lastIdent.value) + } + lastIdent = tok; + this._lastUnterminatedCondition = { + start: tok.start, + type: Parser.aliases[lastIdent.value] || lastIdent.value, + value: '', + } + + continue + } + + // if we don't have an preceding IDENT token + // this must be part of remaingin + if (!lastIdent) { + this.remaining.push(tok.literal); + this._lastUnterminatedCondition = null; + + continue + } + + // we would expect a colon now + if (!hasColon) { + if (tok.type !== TokenType.COLON) { + // we expected a colon but got something else. + // this means the last IDENT is part of remaining + this.remaining.push(lastIdent.value); + lastIdent = null; + this._lastUnterminatedCondition = null; + + continue + } + + // we have a colon now so proceed to the next token + hasColon = true; + not = false; + + continue + } + + if (lastIdent.type === TokenType.GROUPBY) { + groupBy.push(Parser.aliases[tok.literal] || tok.literal) + lastIdent = null + hasColon = false + + continue + } + + if (lastIdent.type == TokenType.ORDERBY) { + orderBy.push(Parser.aliases[tok.literal] || tok.literal) + lastIdent = null + hasColon = false + + continue + } + + if (tok.type === TokenType.NOT && not === false) { + not = true + + continue + } + + if (isValueToken(tok)) { + let identValue = Parser.aliases[lastIdent.value] || lastIdent.value; + + if (!this.conditions[identValue]) { + this.conditions[identValue] = []; + } + + if (!not) { + this.conditions[identValue].push(tok.value) + } else { + this.conditions[identValue].push({ $ne: tok.value }) + } + this._lastUnterminatedCondition!.value = tok.value; + + lastIdent = null + hasColon = false + not = false + + continue + } + + this.remaining.push(lastIdent.value); + lastIdent = null; + hasColon = false; + not = false; + this._lastUnterminatedCondition = null; + } + + if (!!lastIdent) { + this.remaining.push(lastIdent.value); + + if (hasColon) { + this._lastUnterminatedCondition = { + start: lastIdent.start, + type: Parser.aliases[lastIdent.value] || lastIdent.value, + value: '' + }; + } else { + this._lastUnterminatedCondition = null; + } + } + + return { + groupBy: groupBy.length > 0 ? groupBy : undefined, + orderBy: orderBy.length > 0 ? orderBy : undefined, + conditions: this.conditions, + textQuery: this.remaining.filter(tok => !isWhitespace(tok)).join(" "), + } + } +} diff --git a/desktop/angular/src/app/shared/netquery/textql/token.ts b/desktop/angular/src/app/shared/netquery/textql/token.ts new file mode 100644 index 00000000..ae9039d7 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/textql/token.ts @@ -0,0 +1,46 @@ + +/** + * Language Definition: + * + * input: + * + * [EXPR] [EXPR]... + * + * with: + * + * EXPR = [IDENT][COLON][NOT?][VALUE] + * NOT = "!" + * VALUE = [STRING][BOOL][NUMBER] + * STRING = [a-zA-Z\.0-9] + * BOOL = true | false + * NUMBER = [0-9]+ + * COLON = ":" + * + */ + +export enum TokenType { + WHITESPACE = 'WHITESPACE', + IDENT = 'IDENT', + COLON = 'COLON', + STRING = 'STRING', + NUMBER = 'NUMBER', + BOOL = 'BOOL', + NOT = 'NOT', + GROUPBY = 'GROUPBY', + ORDERBY = 'ORDERBY' +} + +export type TokenValue = + T extends TokenType.NUMBER ? number : + T extends TokenType.STRING ? string : + T extends TokenType.BOOL ? boolean : + T extends TokenType.NOT ? '!' : + T extends TokenType.GROUPBY ? 'string' : + string; + +export interface Token { + type: T; + literal: string; + value: TokenValue; + start: number; +} diff --git a/desktop/angular/src/app/shared/netquery/utils.ts b/desktop/angular/src/app/shared/netquery/utils.ts new file mode 100644 index 00000000..95f15034 --- /dev/null +++ b/desktop/angular/src/app/shared/netquery/utils.ts @@ -0,0 +1,63 @@ +import { Condition, Matcher } from "@safing/portmaster-api"; +import { objKeys } from "../utils"; + +export const connectionFieldTranslation: { [key: string]: string } = { + domain: "Domain", + profile: "App", + path: 'Binary Path', + scope: 'Scope', + as_owner: "Provider", + country: "Country", + direction: 'Direction', + started: 'Started', + ended: 'Ended', + remote_ip: 'Remote IP', + verdict: 'Verdict', + encrypted: 'Encrypted', + internal: 'Internal', + asn: 'ASN', + tunneled: 'SPN Active', + active: 'Active', + allowed: 'Allowed', + from: 'From', + to: 'To', + remote_port: 'Port', + bytes_sent: 'Bytes Sent', + bytes_received: 'Bytes Received' +} + +export function isMatcher(v: any | Matcher): v is Matcher { + return typeof v === 'object' && ('$eq' in v || '$ne' in v || '$like' in v || '$in' in v || '$notin' in v); +} + +export function mergeConditions(cond1: Condition, cond2: Condition): Condition { + const result: Condition = {}; + + objKeys(cond1).forEach(key => { + let val = cond1[key]; + if (Array.isArray(val)) { + result[key] = val; + } else { + result[key] = [val]; + } + }) + + objKeys(cond2).forEach(key => { + let val = cond2[key]; + if (!Array.isArray(val)) { + val = [val] + } + + if (!(key in result)) { + result[key] = val; + } else { + result[key] = [ + ...(result[key] as any), // this must be an array here + ...val, + ] + } + }) + + + return result; +} diff --git a/desktop/angular/src/app/shared/network-scout/index.ts b/desktop/angular/src/app/shared/network-scout/index.ts new file mode 100644 index 00000000..fa8417ee --- /dev/null +++ b/desktop/angular/src/app/shared/network-scout/index.ts @@ -0,0 +1 @@ +export * from './network-scout'; diff --git a/desktop/angular/src/app/shared/network-scout/network-scout.html b/desktop/angular/src/app/shared/network-scout/network-scout.html new file mode 100644 index 00000000..b314d86b --- /dev/null +++ b/desktop/angular/src/app/shared/network-scout/network-scout.html @@ -0,0 +1,182 @@ +
+
+ + + + + + {{allProfiles.length}} Apps + +
+ + + + + + + + +
+

Sort By

+ + + + {{ sortMethod }} + + +
+
+ + + + + + + + + +
+ + + +
+ {{ profile.exitPins.length }} + IDENTITIES +
+ + + Connections from {{ profile.Name }} have not been routed through the SPN. + + +
    +
  • + + + + {{ entity.IP }} + + +
    + + {{ identity.count }} + Connections + + + HOPS: + {{ identity.HopDistance }} + +
    +
  • +
+ + + {{ profile.showMore ? 'Show Less Identities' : 'Show More Identities'}} + +
+
+ + +
+ + + + + + + + + {{ data.Name }} + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + +
+ + + + + {{ data.bytes_sent | bytes:"1.0-0" }} + + + + + + + {{ data.bytes_received | bytes:"1.0-0" }} + +
+
+
+
diff --git a/desktop/angular/src/app/shared/network-scout/network-scout.scss b/desktop/angular/src/app/shared/network-scout/network-scout.scss new file mode 100644 index 00000000..9f24cfef --- /dev/null +++ b/desktop/angular/src/app/shared/network-scout/network-scout.scss @@ -0,0 +1,3 @@ +:host { + @apply w-full p-2 flex flex-col gap-2; +} diff --git a/desktop/angular/src/app/shared/network-scout/network-scout.ts b/desktop/angular/src/app/shared/network-scout/network-scout.ts new file mode 100644 index 00000000..8c3c7d88 --- /dev/null +++ b/desktop/angular/src/app/shared/network-scout/network-scout.ts @@ -0,0 +1,322 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, TrackByFunction, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BoolSetting, Condition, ConfigService, ExpertiseLevel, IProfileStats, Netquery, Pin, SPNService } from "@safing/portmaster-api"; +import { Subject, combineLatest, debounceTime, filter, finalize, interval, retry, startWith, switchMap, take, takeUntil } from "rxjs"; +import { UIStateService } from "src/app/services"; +import { fadeInListAnimation } from "../animations"; +import { ExpertiseService } from './../expertise/expertise.service'; + +interface _Pin extends Pin { + count: number; +} + +interface _Profile extends IProfileStats { + exitPins: _Pin[]; + showMore: boolean; + expanded: boolean; +} + +export enum SortTypes { + static = 'Static', + aToZ = "A-Z", + zToA = "Z-A", + totalConnections = "Total Connections", + connectionsDenied = "Denied Connections", + connectionsAllowed = "Allowed Connections", + spnIdentities = "SPN Identities", + bytesSent = "Bytes Sent", + bytesReceived = "Bytes Received", + totalBytes = "Total Bytes" +} + +const bandwidthSorts: SortTypes[] = [ + SortTypes.bytesReceived, + SortTypes.bytesSent, + SortTypes.totalBytes +] + +@Component({ + selector: 'app-network-scout', + templateUrl: './network-scout.html', + styleUrls: ['./network-scout.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInListAnimation, + ] +}) +export class NetworkScoutComponent implements OnInit { + private destroyRef = inject(DestroyRef); + + sortTypes = [ + SortTypes.static, + SortTypes.aToZ, + SortTypes.zToA, + SortTypes.totalConnections, + SortTypes.connectionsDenied, + SortTypes.connectionsAllowed, + SortTypes.spnIdentities + ] + + readonly sortMethods = new Map([ + // there's not entry for "Static" here on purpose because we'll use the sort order + // returned by netquery. + [SortTypes.aToZ, (a: _Profile, b: _Profile) => a.Name.localeCompare(b.Name)], + [SortTypes.zToA, (a: _Profile, b: _Profile) => b.Name.localeCompare(a.Name)], + [SortTypes.totalConnections, (a: _Profile, b: _Profile) => (b.countAllowed + b.countUnpermitted) - (a.countAllowed + a.countUnpermitted)], + [SortTypes.connectionsAllowed, (a: _Profile, b: _Profile) => b.countAllowed - a.countAllowed], + [SortTypes.connectionsDenied, (a: _Profile, b: _Profile) => b.countUnpermitted - a.countUnpermitted], + [SortTypes.spnIdentities, (a: _Profile, b: _Profile) => a.identities.length - b.identities.length], + [SortTypes.bytesReceived, (a: _Profile, b: _Profile) => b.bytes_received - a.bytes_received], + [SortTypes.bytesSent, (a: _Profile, b: _Profile) => b.bytes_sent - a.bytes_sent], + [SortTypes.totalBytes, (a: _Profile, b: _Profile) => (b.bytes_received + b.bytes_sent) - (a.bytes_received + a.bytes_sent)] + ]); + + /** The current sort order */ + sortOrder: SortTypes = SortTypes.static; + + get isByteSortOrder() { + return bandwidthSorts.includes(this.sortOrder); + } + + /** Used to trigger a debounced search from the template */ + triggerSearch = new Subject(); + + /** The current search term as entered in the input[type="text"] */ + searchTerm: string = ''; + + /** A list of all active profiles without any search applied */ + allProfiles: _Profile[] = []; + + /** Defines if new elements should be expanded or collapsed */ + expandCollapseState: 'expand' | 'collapse' = 'expand'; + + /** Whether or not the SPN is enabled */ + spnEnabled = false; + + /** + * Emits when the user clicks the "expand all" or "collapse all" buttons. + * Once the user did that we stop updating the default state depending on whether the + * SPN is enabled or not. + */ + private userChangedState = new Subject(); + + /** + * A list of profiles that are currently displayed. This is basically allProfiles but with + * text search applied. + */ + profiles: _Profile[] = []; + + /** TrackByFunction for the profiles. */ + trackProfile: TrackByFunction<_Profile> = (_, profile) => profile.ID; + + /** TrackByFunction for the exit pins */ + trackPin: TrackByFunction<_Pin> = (_, pin) => pin.ID; + + constructor( + private netquery: Netquery, + private spn: SPNService, + private configService: ConfigService, + private stateService: UIStateService, + private expertise: ExpertiseService, + private cdr: ChangeDetectorRef, + ) { } + + searchProfiles(term: string) { + term = term.trim(); + + if (term === '') { + this.profiles = [ + ...this.allProfiles + ]; + + this.sortProfiles(this.profiles); + + return; + } + + const lowerCaseTerm = term.toLocaleLowerCase() + this.profiles = this.allProfiles.filter(p => { + if (p.ID.toLocaleLowerCase().includes(lowerCaseTerm)) { + return true; + } + + if (p.Name.toLocaleLowerCase().includes(lowerCaseTerm)) { + return true; + } + + if (p.exitPins.some(pin => pin.Name.toLocaleLowerCase().includes(lowerCaseTerm))) { + return true; + } + + return false; + }) + + this.sortProfiles(this.profiles); + } + + sortProfiles(profiles: _Profile[]) { + const method = this.sortMethods.get(this.sortOrder); + if (!method) { + return; + } + + profiles.sort(method) + + this.cdr.markForCheck(); + } + + updateSortOrder(newOrder: SortTypes) { + this.sortOrder = newOrder; + this.searchProfiles(this.searchTerm); + + this.stateService.set('netscoutSortOrder', newOrder) + .subscribe({ + error: err => { + console.error(err); + } + }) + } + + expandAll() { + this.expandCollapseState = 'expand'; + this.allProfiles.forEach(profile => profile.expanded = profile.identities.length > 0) + this.searchProfiles(this.searchTerm) + this.userChangedState.next(); + + this.cdr.markForCheck() + } + + collapseAll() { + this.expandCollapseState = 'collapse'; + this.allProfiles.forEach(profile => profile.expanded = false) + this.searchProfiles(this.searchTerm) + this.userChangedState.next(); + + this.cdr.markForCheck() + } + + ngOnInit(): void { + this.stateService.uiState() + .pipe(take(1)) + .subscribe(state => { + this.sortOrder = state.netscoutSortOrder; + + this.searchProfiles(this.searchTerm); + }) + + this.configService.watch('spn/enable') + .pipe( + takeUntilDestroyed(this.destroyRef), + takeUntil(this.userChangedState), + ) + .subscribe(enabled => { + // if the SPN is enabled and the user did not yet change the + // collapse/expand state we switch to "expand" for the default. + // Otherwise, there will be no identities so there's no reason + // to expand them at all so we switch to collapse + if (enabled) { + this.expandCollapseState = 'expand' + } else { + this.expandCollapseState = 'collapse' + } + + this.spnEnabled = enabled; + }); + + let updateInProgress = false; + + combineLatest([ + combineLatest([ + interval(5000) + .pipe( + filter(() => !updateInProgress) + ), + this.expertise.change, + ]) + .pipe( + startWith(-1), + switchMap(() => { + let query: Condition = {}; + if (this.expertise.currentLevel !== ExpertiseLevel.Developer) { + query["internal"] = { $eq: false } + } + + updateInProgress = true + + return this.netquery.getProfileStats(query) + .pipe( + finalize(() => updateInProgress = false) + ) + }), + retry({ delay: 5000 }) + ), + + this.spn.watchPins() + .pipe( + debounceTime(100), + startWith([]), + ), + + this.triggerSearch + .pipe( + debounceTime(100), + startWith(''), + ), + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([res, pins, searchTerm]) => { + // create a lookup map for the the SPN map pins + const pinLookupMap = new Map(); + pins.forEach(p => pinLookupMap.set(p.ID, p)) + + // create a lookup map from already known profiles so we can + // inherit states like "showMore". + const profileLookupMap = new Map(); + this.allProfiles.forEach(p => profileLookupMap.set(p.ID, p)) + + // map the list of profile statistics to include the exit Pin information + // as well. + this.allProfiles = res.map(s => { + const existing = profileLookupMap.get(s.ID); + return { + ...s, + exitPins: s.identities + .map(ident => { + const pin = pinLookupMap.get(ident.exit_node); + if (!pin) { + return null; + } + + return { + count: ident.count, + ...pin + } + }) + .filter(pin => !!pin), + showMore: existing?.showMore ?? false, + expanded: existing?.expanded ?? (this.expandCollapseState === 'expand' && s.identities.length > 1 /* there's always the "direct" identity */), + } as _Profile + }); + + this.searchProfiles(searchTerm); + + // check if we have profiles with bandwidth data and + // make sure our sort methods are updated. + if (this.profiles.some(p => p.bytes_received > 0 || p.bytes_sent > 0)) { + if (!this.sortTypes.includes(SortTypes.bytesReceived)) { + this.sortTypes.push.apply(this.sortTypes, bandwidthSorts) + } + + this.sortTypes = [...this.sortTypes]; + } else { + this.sortTypes = this.sortTypes.filter(type => { + return !bandwidthSorts.includes(type) + }) + } + + this.cdr.markForCheck(); + }) + } +} diff --git a/desktop/angular/src/app/shared/notification-list/index.ts b/desktop/angular/src/app/shared/notification-list/index.ts new file mode 100644 index 00000000..3fa640e3 --- /dev/null +++ b/desktop/angular/src/app/shared/notification-list/index.ts @@ -0,0 +1 @@ +export { NotificationListComponent as NotificationWidgetComponent, NotificationWidgetConfig } from './notification-list.component'; diff --git a/desktop/angular/src/app/shared/notification-list/notification-list.component.html b/desktop/angular/src/app/shared/notification-list/notification-list.component.html new file mode 100644 index 00000000..23f2cb08 --- /dev/null +++ b/desktop/angular/src/app/shared/notification-list/notification-list.component.html @@ -0,0 +1,24 @@ +Notifications + +
+
+
+ + + +
+
+ + {{notif.Title || notif.Message}} + +
+ +
+
+
+
diff --git a/desktop/angular/src/app/shared/notification-list/notification-list.component.scss b/desktop/angular/src/app/shared/notification-list/notification-list.component.scss new file mode 100644 index 00000000..e99c059e --- /dev/null +++ b/desktop/angular/src/app/shared/notification-list/notification-list.component.scss @@ -0,0 +1,186 @@ +:host { + @apply flex flex-col justify-start items-center gap-2; + @apply w-full px-2; + + @apply border-b border-gray-400 pb-2; + + &>* { + /* do not allow to shrink */ + flex-shrink: 0; + } +} + +.row, +div.placeholder { + display: flex; + flex-direction: column; + width: 100%; + margin: 0; + border: none; +} + +.row { + @apply overflow-hidden w-full flex flex-row rounded; + @apply h-8; + + .type { + display: flex; + justify-content: center; + align-items: center; + width: .5rem; + flex-shrink: 0; + flex-grow: 0; + background-color: #202020; + + &.info { + background-color: #727272; + } + + &.warning { + background-color: theme("colors.info.yellow"); + } + + &.error { + background-color: theme("colors.info.red"); + } + + &.broadcast { + width: 2rem; + color: #00000080; + } + } + + .preview { + background-color: #292929; + cursor: pointer; + overflow: hidden; + flex-grow: 1; + display: flex; + justify-content: space-between; + align-items: center; + padding-left: 1rem; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + position: relative; + + span { + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + word-wrap: none; + white-space: nowrap; + + font-size: 0.7rem; + font-weight: 500; + + color: #cacaca; + + .category { + padding-left: 8px; + font-size: 0.65rem; + font-weight: 700; + text-transform: capitalize; + color: #999999c9; + } + } + + &:hover { + background-color: #303030; + + .buttons { + opacity: 1; + transition: all .05s ease-in-out; + transform: translateX(-100%); + } + } + + .buttons { + opacity: 0; + transition: all .05s ease-in-out; + height: 100%; + position: absolute; + left: 100%; + display: flex; + white-space: nowrap; + background-color: #303030; + + button { + outline: none; + @apply bg-transparent; + font-size: 0.6rem; + background-color: #3a3a3a; + padding-left: 1.25rem; + padding-right: 1.25rem; + text-transform: capitalize; + border-radius: 0; + font-weight: 500; + outline: none; + color: hsla(0, 0%, 100%, 0.548); + height: 100%; + + &:hover { + background-color: #363636; + color: #ffffff; + } + + &:first-of-type { + margin-left: .5rem; + } + + &:last-of-type { + background: transparent; + color: hsla(0, 0%, 100%, 0.562); + @apply ml-1; + transition: all cubic-bezier(0.175, 0.885, 0.32, 1.275) .2s; + + &:hover { + color: #ffffff; + } + } + } + } + } +} + +/* +.notification-body { + @apply bg-cards-tertiary; + flex-grow: 1; + @apply rounded-b; + position: absolute; + top: var(--slot-size); + bottom: 0; + + .broadcast-info { + background-color: #00000040; + width: 100%; + padding: 0.5rem; + color: white !important; + font-weight: 400; + bottom: 0; + position: absolute; + flex-grow: 1; + @apply flex items-center justify-center gap-1; + } +} +*/ + +div.placeholder { + @apply font-medium; + @apply text-tertiary; + @apply flex-grow; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + user-select: none; +} + +app-loading { + opacity: .5; + margin-left: auto; + margin-right: auto; + position: relative; + top: 5px; +} diff --git a/desktop/angular/src/app/shared/notification-list/notification-list.component.ts b/desktop/angular/src/app/shared/notification-list/notification-list.component.ts new file mode 100644 index 00000000..d54e2b98 --- /dev/null +++ b/desktop/angular/src/app/shared/notification-list/notification-list.component.ts @@ -0,0 +1,138 @@ +import { animate, style, transition, trigger } from '@angular/animations'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, OnDestroy, OnInit, TrackByFunction, inject } from '@angular/core'; +import { SfngDialogService } from '@safing/ui'; +import { Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Action, Notification, NotificationType, NotificationsService } from 'src/app/services'; +import { moveInOutAnimation, moveInOutListAnimation } from 'src/app/shared/animations'; +import { NotificationComponent } from '../notification/notification'; + +export interface NotificationWidgetConfig { + markdown: boolean; +} + +export interface _Notification extends Notification { + isBroadcast: boolean +} + +@Component({ + selector: 'app-notification-list', + templateUrl: './notification-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: [ + './notification-list.component.scss' + ], + animations: [ + trigger( + 'fadeIn', + [ + transition( + ':enter', + [ + style({ opacity: 0 }), + animate('.2s .2s ease-in', + style({ opacity: 1 })) + ] + ), + ] + ), + moveInOutAnimation, + moveInOutListAnimation + ] +}) +export class NotificationListComponent implements OnInit, OnDestroy { + readonly types = NotificationType; + readonly dialog = inject(SfngDialogService); + readonly cdr = inject(ChangeDetectorRef); + + /** Used to set a fixed height when a notification is expanded. */ + @HostBinding('style.height') + height: null | string = null; + + /** Sets the overflow to hidden when a notification is expanded. */ + @HostBinding('style.overflow') + get overflow() { + if (this.height === null) { + return null; + } + return 'hidden'; + } + + @HostBinding('class.empty') + get isEmpty() { + return this.notifications.length === 0; + } + + @HostBinding('@moveInOutList') + get length() { return this.notifications.length } + + /** Subscription to notification updates. */ + private notifSub = Subscription.EMPTY; + + /** All active notifications. */ + notifications: _Notification[] = []; + + trackBy: TrackByFunction<_Notification> = this.notifsService.trackBy; + + constructor( + public elementRef: ElementRef, + public notifsService: NotificationsService, + ) { } + + ngOnInit(): void { + this.notifSub = this.notifsService + .new$ + .pipe( + // filter out any prompts as they are handled by a different widget. + map(notifs => { + return notifs.filter(notif => !notif.SelectedActionID && !(notif.Type === NotificationType.Prompt && notif.EventID.startsWith("filter:prompt"))) + }) + ) + .subscribe(list => { + this.notifications = list.map(notification => { + return { + ...notification, + isBroadcast: notification.EventID.startsWith("broadcasts:"), + } + }); + + this.cdr.markForCheck(); + }); + } + + ngOnDestroy() { + this.notifSub.unsubscribe(); + } + + /** + * @private + * + * Executes a notification action and updates the "expanded-notification" + * view if required. + * + * @param n The notification object. + * @param actionId The ID of the action to execute. + * @param event The mouse click event. + */ + execute(n: _Notification, action: Action, event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.notifsService.execute(n, action) + .subscribe() + } + + /** + * @private + * Toggles between list mode and notification-view mode. + * + * @param notif The notification that has been clicked. + */ + toggelView(notif: _Notification) { + const ref = this.dialog.create(NotificationComponent, { + backdrop: 'light', + autoclose: true, + data: notif, + }); + } +} diff --git a/desktop/angular/src/app/shared/notification/notification.html b/desktop/angular/src/app/shared/notification/notification.html new file mode 100644 index 00000000..c3d7bcf6 --- /dev/null +++ b/desktop/angular/src/app/shared/notification/notification.html @@ -0,0 +1,27 @@ +
+ Notification + + + Broadcast Notification + + + + + + + +

{{notification.Title}}

+ + + +
+ +
+
diff --git a/desktop/angular/src/app/shared/notification/notification.scss b/desktop/angular/src/app/shared/notification/notification.scss new file mode 100644 index 00000000..5142a8c0 --- /dev/null +++ b/desktop/angular/src/app/shared/notification/notification.scss @@ -0,0 +1,48 @@ +:host { + @apply block; + max-width: 24rem; +} + +caption { + @apply text-xxs; + opacity: .6; +} + +h1 { + @apply text-base font-normal my-4; +} + +.message, +h1 { + flex-shrink: 0; + text-overflow: ellipsis; + word-break: normal; +} + +.message { + flex-grow: 1; + padding: 0; +} + +.close-icon { + position: absolute; + top: 1rem; + right: 1rem; + opacity: .7; + cursor: pointer; + + &:hover { + opacity: 1; + } +} + +.buttons { + width: 100%; + display: flex; + + @apply flex flex-row justify-end gap-2; +} + +a { + text-decoration: underline; +} diff --git a/desktop/angular/src/app/shared/notification/notification.ts b/desktop/angular/src/app/shared/notification/notification.ts new file mode 100644 index 00000000..a50dcdee --- /dev/null +++ b/desktop/angular/src/app/shared/notification/notification.ts @@ -0,0 +1,65 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, OnInit, Output, inject } from '@angular/core'; +import { SFNG_DIALOG_REF } from '@safing/ui'; +import { Action, NotificationState, NotificationsService, getNotificationTypeString } from '../../services'; +import { _Notification } from '../notification-list/notification-list.component'; + +@Component({ + selector: 'app-notification', + templateUrl: './notification.html', + styleUrls: ['./notification.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotificationComponent implements OnInit { + readonly ref = inject(SFNG_DIALOG_REF); + readonly notification: _Notification = inject(SFNG_DIALOG_REF).data; + + /** + * The host tag of the notification component has the notification type + * and the notification state as a class name set. + * Examples: + * + * notif-action-required notif-prompt + */ + @HostBinding('class') + get hostClass(): string { + let cls = `notif-${this.state}`; + if (!!this.notification) { + cls = `${cls} notif-${getNotificationTypeString(this.notification.Type)}` + } + return cls + } + + state: NotificationState = NotificationState.Invalid; + + ngOnInit() { + if (!!this.notification) { + this.state = this.notification.State || NotificationState.Invalid; + } else { + this.state = NotificationState.Invalid; + } + } + + @Input() + set allowMarkdown(v: any) { + this._markdown = coerceBooleanProperty(v); + } + get allowMarkdown() { return this._markdown; } + private _markdown: boolean = true; + + @Output() + actionExecuted: EventEmitter = new EventEmitter(); + + constructor(private notifService: NotificationsService) { } + + execute(n: _Notification, action: Action) { + this.notifService.execute(n, action) + .subscribe( + () => { + this.actionExecuted.next(action) + this.ref.close(); + }, + err => console.error(err), + ) + } +} diff --git a/desktop/angular/src/app/shared/pipes/bytes.pipe.ts b/desktop/angular/src/app/shared/pipes/bytes.pipe.ts new file mode 100644 index 00000000..a0be5486 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/bytes.pipe.ts @@ -0,0 +1,28 @@ +import { DecimalPipe } from "@angular/common"; +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + pure: true, + name: 'bytes', +}) +export class BytesPipe implements PipeTransform { + transform(value: any, decimal: string = '1.0-2', ...args: any[]) { + value = +value; // convert to number + + const ceilings = [ + 'B', + 'kB', + 'MB', + 'GB', + 'TB' + ] + + let idx = 0; + while (value > 1024 && idx < ceilings.length - 1) { + value = value / 1024; + idx++ + } + + return (new DecimalPipe('en-US')).transform(value, decimal) + ' ' + ceilings[idx]; + } +} diff --git a/desktop/angular/src/app/shared/pipes/common-pipes.module.ts b/desktop/angular/src/app/shared/pipes/common-pipes.module.ts new file mode 100644 index 00000000..c64df8a1 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/common-pipes.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from "@angular/core"; +import { BytesPipe } from "./bytes.pipe"; +import { TimeAgoPipe } from "./time-ago.pipe"; +import { ToAppProfilePipe } from "./to-profile.pipe"; +import { DurationPipe } from "./duration.pipe"; +import { RoundPipe } from "./round.pipe"; +import { ToSecondsPipe } from "./to-seconds.pipe"; + +@NgModule({ + declarations: [ + TimeAgoPipe, + BytesPipe, + ToAppProfilePipe, + DurationPipe, + RoundPipe, + ToSecondsPipe + ], + exports: [ + TimeAgoPipe, + BytesPipe, + ToAppProfilePipe, + DurationPipe, + RoundPipe, + ToSecondsPipe + ] +}) +export class CommonPipesModule { } diff --git a/desktop/angular/src/app/shared/pipes/duration.pipe.ts b/desktop/angular/src/app/shared/pipes/duration.pipe.ts new file mode 100644 index 00000000..df33c283 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/duration.pipe.ts @@ -0,0 +1,103 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +const millisecond = 1; +const second = 1000 * millisecond; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; + +export function formatDuration(millis: number, skipDays = false, skipMillis = false): string { + const sign = millis < 0 ? '-' : ''; + let val = Math.abs(millis); + let str = ''; + + if (millis === 0) { + return '0'; + } + + if (!skipDays) { + const days = Math.floor(val / day) + if (days > 0) { + str += days.toString() + 'd '; + val -= days * day; + } + } + + const hours = Math.floor(val / hour); + if (hours > 0) { + str += hours.toString() + 'h '; + val -= hours * hour; + } + + const minutes = Math.floor(val / minute); + if (minutes > 0) { + str += minutes.toString() + 'm '; + val -= minutes * minute; + } + + const seconds = Math.floor(val / second); + if (seconds > 0) { + str += seconds.toString() + 's '; + val -= seconds * second; + } + + if (!skipMillis) { + const ms = Math.floor(val / millisecond) + if (ms > 0) { + str += ms.toString() + 'ms ' + val -= ms * millisecond + } + } + + if (str.endsWith("")) { + str = str.substring(0, str.length - 1) + } + + return sign + str; +} + +@Pipe({ + name: 'duration', + pure: true +}) +export class DurationPipe implements PipeTransform { + transform(value: number | [string, string] | [Date, Date] | [number, number], ...args: any[]) { + if (Array.isArray(value)) { + let firstNum: number; + let secondNum: number; + + let [first, second] = value; + if (first instanceof Date || typeof first === 'string') { + first = new Date(first) + firstNum = first.getTime() + } else { + firstNum = first + } + if (second instanceof Date || typeof second === 'string') { + second = new Date(second); + secondNum = second.getTime() + } else { + secondNum = second + } + + if (secondNum < firstNum) { + const t = firstNum; + firstNum = secondNum + secondNum = t + } + + value = secondNum - firstNum + } + + if (value < second) { + + } + + const result = formatDuration(value); + if (result === '0') { + return '< 1s' + } + + return result + } +} diff --git a/desktop/angular/src/app/shared/pipes/index.ts b/desktop/angular/src/app/shared/pipes/index.ts new file mode 100644 index 00000000..6eddfdd2 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/index.ts @@ -0,0 +1,6 @@ +export * from './common-pipes.module'; +export * from './time-ago.pipe'; +export * from './to-profile.pipe'; +export * from './duration.pipe'; +export * from './to-seconds.pipe'; +export * from './round.pipe'; diff --git a/desktop/angular/src/app/shared/pipes/round.pipe.ts b/desktop/angular/src/app/shared/pipes/round.pipe.ts new file mode 100644 index 00000000..104ca0ac --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/round.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'round', + pure: true, +}) +export class RoundPipe implements PipeTransform { + transform(value: number, roundBy: number) { + if (isNaN(value)) { + return NaN + } + + return Math.floor(value / roundBy) * roundBy + } +} diff --git a/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts b/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts new file mode 100644 index 00000000..25f53ac7 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts @@ -0,0 +1,56 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'timeAgo', + pure: true +}) +export class TimeAgoPipe implements PipeTransform { + transform(value: number | Date | string, ticker?: any): string { + return timeAgo(value); + } +} + +export const timeCeilings = [ + { ceiling: 1, text: "" }, + { ceiling: 60, text: "sec" }, + { ceiling: 3600, text: "min" }, + { ceiling: 86400, text: "hour" }, + { ceiling: 2629744, text: "day" }, + { ceiling: 31556926, text: "month" }, + { ceiling: Infinity, text: "year" } +] + +export function timeAgo(value: number | Date | string) { + if (typeof value === 'string') { + value = new Date(value) + } + + if (value instanceof Date) { + value = value.valueOf() / 1000; + } + + let suffix = 'ago' + + let diffInSeconds = Math.floor(((new Date()).valueOf() - (value * 1000)) / 1000); + if (diffInSeconds < 0) { + diffInSeconds = diffInSeconds * -1; + suffix = '' + } + + for (let i = timeCeilings.length - 1; i >= 0; i--) { + const f = timeCeilings[i]; + let n = Math.floor(diffInSeconds / f.ceiling); + if (n > 0) { + if (i < 1) { + return `< 1 min ` + suffix; + } + let text = timeCeilings[i + 1].text; + if (n > 1) { + text += 's'; + } + return `${n} ${text} ` + suffix + } + } + + return "< 1 min" + suffix // actually just now (diffInSeconds == 0) +} diff --git a/desktop/angular/src/app/shared/pipes/to-profile.pipe.ts b/desktop/angular/src/app/shared/pipes/to-profile.pipe.ts new file mode 100644 index 00000000..217e3b35 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/to-profile.pipe.ts @@ -0,0 +1,35 @@ +import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform, inject } from "@angular/core"; +import { AppProfile, AppProfileService } from "@safing/portmaster-api"; +import { Subscription } from "rxjs"; + +@Pipe({ + name: 'toAppProfile', + pure: false +}) +export class ToAppProfilePipe implements PipeTransform, OnDestroy { + profileService = inject(AppProfileService); + cdr = inject(ChangeDetectorRef); + + private _lastProfile: AppProfile | null = null; + private _lastKey: string | null = null; + private _subscription = Subscription.EMPTY; + + transform(key: string): AppProfile | null { + if (key !== this._lastKey) { + this._lastKey = key; + + this._subscription.unsubscribe(); + this._subscription = this.profileService.watchAppProfile(key) + .subscribe(value => { + this._lastProfile = value; + this.cdr.markForCheck(); + }) + } + + return this._lastProfile || null; + } + + ngOnDestroy(): void { + this._subscription.unsubscribe(); + } +} diff --git a/desktop/angular/src/app/shared/pipes/to-seconds.pipe.ts b/desktop/angular/src/app/shared/pipes/to-seconds.pipe.ts new file mode 100644 index 00000000..166fbf17 --- /dev/null +++ b/desktop/angular/src/app/shared/pipes/to-seconds.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: 'toSeconds', + pure: true, +}) +export class ToSecondsPipe implements PipeTransform { + transform(value: Date | string, ...args: any[]) { + if (value === null || value === undefined) { + return NaN + } + + if (typeof value === 'string') { + value = new Date(value); + } + + return Math.floor(value.getTime() / 1000) + } +} diff --git a/desktop/angular/src/app/shared/process-details-dialog/index.ts b/desktop/angular/src/app/shared/process-details-dialog/index.ts new file mode 100644 index 00000000..cceeb5f8 --- /dev/null +++ b/desktop/angular/src/app/shared/process-details-dialog/index.ts @@ -0,0 +1 @@ +export * from './process-details-dialog'; diff --git a/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.html b/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.html new file mode 100644 index 00000000..85cfa1bb --- /dev/null +++ b/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.html @@ -0,0 +1,131 @@ +

+ Process Details +

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name +
+ + {{ process.Name }} +
+
User{{ process.UserName }} ({{ process.UserID + }})
Process ID{{ process.Pid }}
Process Group ID{{ process.Pgid }}
Parent Process ID{{ process.ParentPid }}
Path{{ process.Path }} ({{ process.MatchingPath + }}) + +
Executable Name{{ process.ExecName }}
Command Line{{ process.CmdLine }} + +
+
+ Tags + +
+
+ This process does not have any tags. +
    +
  • + {{ tag.Key }} + {{ tag.Value }} +
  • +
+
+
+
+ + +
+
+ This process does not have any environment variables. +
+ + + + + + + + + +
{{ env.key }}{{ env.value }} + +
+
+
+
+ +
+ +
\ No newline at end of file diff --git a/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.scss b/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.scss new file mode 100644 index 00000000..609e4e1f --- /dev/null +++ b/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.scss @@ -0,0 +1,32 @@ +:host { + @apply flex flex-col gap-4 max-w-2xl; + min-width: 500px; + width: 60vw; + + min-height: 500px; + height: 60vh; + max-height: 80vh; + overflow: hidden; +} + +table.custom { + @apply w-full overflow-hidden; + + th, + td { + @apply px-2 align-top py-2; + } + + th { + text-align: left; + @apply w-32 text-secondary; + } + + td { + @apply whitespace-normal break-all; + } + + td:last-of-type { + @apply p-0; + } +} diff --git a/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.ts b/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.ts new file mode 100644 index 00000000..1f0daa0c --- /dev/null +++ b/desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.ts @@ -0,0 +1,102 @@ +import { KeyValue } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { AppProfile, AppProfileService, FingerpringOperation, Fingerprint, FingerprintType, PortapiService, Process } from '@safing/portmaster-api'; +import { SfngDialogRef, SfngDialogService, SFNG_DIALOG_REF } from '@safing/ui'; +import { EditProfileDialog } from '../edit-profile-dialog'; + +@Component({ + selector: 'app-process-details', + templateUrl: './process-details-dialog.html', + styleUrls: ['./process-details-dialog.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProcessDetailsDialogComponent { + process: (Process & { ID: string }); + + constructor( + @Inject(SFNG_DIALOG_REF) private dialogRef: SfngDialogRef, + private dialog: SfngDialogService, + private portapi: PortapiService, + private profileService: AppProfileService + ) { + this.process = { + ...this.dialogRef.data, + ID: this.dialogRef.data.PrimaryProfileID, + } + } + + close() { + this.dialogRef.close(); + } + + createProfileForPath() { + this.createProfileWithFingerprint({ + Type: FingerprintType.Path, + Key: '', + Value: this.process.MatchingPath || this.process.Path, + Operation: FingerpringOperation.Equal, + }) + } + + createProfileForCmdline() { + this.createProfileWithFingerprint({ + Type: FingerprintType.Cmdline, + Key: '', + Value: this.process.CmdLine, + Operation: FingerpringOperation.Equal, + }) + } + + createProfileForEnv(env: KeyValue) { + const fp: Fingerprint = { + Type: FingerprintType.Env, + Key: env.key, + Value: env.value, + Operation: FingerpringOperation.Equal, + } + + this.createProfileWithFingerprint(fp) + } + + openParent() { + if (!!this.process.ParentPid) { + this.portapi.get(`network:tree/${this.process.ParentPid}-${this.process.ParentCreatedAt}`) + .subscribe(process => { + this.process = { + ...process, + ID: process.PrimaryProfileID, + }; + }) + } + } + + openGroup() { + this.profileService.getProcessByPid(this.process.Pid) + .subscribe(result => { + if (!result) { + return; + } + + this.process = { + ...result, + ID: result.PrimaryProfileID + }; + }) + } + + private createProfileWithFingerprint(fp: Fingerprint) { + let profilePreset: Partial = { + Fingerprints: [ + fp + ] + }; + + this.dialog.create(EditProfileDialog, { + data: profilePreset, + backdrop: true, + autoclose: false, + }) + + this.dialogRef.close(); + } +} diff --git a/desktop/angular/src/app/shared/prompt-list/index.ts b/desktop/angular/src/app/shared/prompt-list/index.ts new file mode 100644 index 00000000..b76fae32 --- /dev/null +++ b/desktop/angular/src/app/shared/prompt-list/index.ts @@ -0,0 +1 @@ +export { PromptListComponent as PromptWidgetComponent } from './prompt-list.component'; diff --git a/desktop/angular/src/app/shared/prompt-list/prompt-list.component.html b/desktop/angular/src/app/shared/prompt-list/prompt-list.component.html new file mode 100644 index 00000000..4d32a21e --- /dev/null +++ b/desktop/angular/src/app/shared/prompt-list/prompt-list.component.html @@ -0,0 +1,68 @@ +
+
+
+ + {{ profile.Name }} + {{ profile.prompts.length }} + + + Per Connection + Allow All + Block All + + Default Action + Allow App + Block App + + Change Default + + + App Settings + + +
+ +
+
+
+
+ + + {{ prompt.EventData?.Entity?.IP || 'N/A' }} + + + {{prompt.subdomain}}.{{prompt.domain}} + + + + + + + +
+
+ {{ profile.prompts.length - profile.promptsLimited.length }} + more +
+ +
+ Show less +
+
+
+
+ +
+ + + + + No Prompts +
+
\ No newline at end of file diff --git a/desktop/angular/src/app/shared/prompt-list/prompt-list.component.scss b/desktop/angular/src/app/shared/prompt-list/prompt-list.component.scss new file mode 100644 index 00000000..90b34aad --- /dev/null +++ b/desktop/angular/src/app/shared/prompt-list/prompt-list.component.scss @@ -0,0 +1,204 @@ +:host { + overflow: hidden; + max-height: 50vh; + display: flex; + flex-direction: column; + min-height: 10rem; + @apply w-80; + @apply bg-gray-300; + + padding-top: 1px; + padding-bottom: 3px; +} + +app-icon { + --app-icon-size: 13px; +} + +.scrollable { + @apply p-0; +} + +.group { + @apply mb-3; + + .group-header { + @apply px-2; + display: flex; + align-items: center; + margin-left: 4px; + height: 2rem; + + .app-name { + flex-grow: 1; + font-size: 0.7rem; + font-weight: 500; + color: #cacaca; + } + + span.prompt-count { + @apply mr-1; + font-size: 0.6rem; + font-weight: 600; + color: #cacaca; + transform: scale(0.95); + user-select: none; + } + } +} + +app-menu-item.item-seperator { + @apply border-t; + @apply border-buttons-dark +} + +.no-prompts { + @apply text-tertiary flex-grow; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + user-select: none; +} + +.prompts { + display: flex; + + .border { + margin-left: calc(0.5rem + 9px); + width: 0.5rem; + border-left-width: 2px; + border-bottom-width: 2px; + border-color: #292929; + } + + .prompt-container, + .prompt, + .actions { + display: flex; + } + + .prompt-container { + flex-grow: 1; + flex-direction: column; + padding-left: 0.6rem; + padding-right: 0.5rem; + padding-top: 0.4rem; + padding-bottom: 1rem; + + .prompt { + padding-left: 0.75rem; + margin-bottom: 4px; + background-color: #292929; + height: auto; + border-radius: 2px; + align-items: center; + overflow: hidden; + position: relative; + + &:hover { + background-color: #303030; + + .actions { + animation: .07s slidein-left ease-in-out; + opacity: 1; + transition: all .05s ease-in-out; + } + } + + .entity { + flex-grow: 1; + word-break: break-all; + white-space: normal; + font-size: 0.7rem; + font-weight: 500; + padding-top: 0.6rem; + padding-bottom: 0.6rem; + padding-left: 2px; + padding-right: 9px; + color: #cacaca; + + .subdomain { + font-size: 0.7rem; + font-weight: 500; + color: #999999; + } + } + + .actions { + min-width: 5rem; + flex-wrap: wrap; + height: 100%; + opacity: 0; + transition: all .05s ease-in-out; + position: absolute; + right: 0; + background-color: #292929; + + button { + outline: none; + @apply bg-transparent; + font-size: 0.6rem; + background-color: #3a3a3a; + padding-left: 1.25rem; + padding-right: 1.25rem; + text-transform: capitalize; + border-radius: 0; + font-weight: 500; + outline: none; + color: hsla(0, 0%, 100%, 0.548); + + padding-left: 1.25rem; + padding-right: 1.25rem; + text-transform: capitalize; + border-radius: 0; + font-weight: 500; + outline: none; + color: hsla(0, 0%, 100%, 0.548); + + &:hover { + background-color: #363636; + color: #ffffff; + } + + &:last-of-type { + background: transparent; + color: hsla(0, 0%, 100%, 0.562); + @apply ml-1; + transition: all cubic-bezier(0.175, 0.885, 0.32, 1.275) .2s; + + &:hover { + color: #ffffff; + } + } + } + } + } + } + + .more-available { + position: relative; + top: 1.4rem; + margin-top: -1rem; + cursor: pointer; + font-size: 0.7rem; + font-weight: 500; + color: #999999; + user-select: none; + + &:hover { + color: #cacaca; + } + } +} + +@keyframes slidein-left { + 0% { + transform: translateX(100%); + } + + 100% { + transform: translateX(0); + } +} diff --git a/desktop/angular/src/app/shared/prompt-list/prompt-list.component.ts b/desktop/angular/src/app/shared/prompt-list/prompt-list.component.ts new file mode 100644 index 00000000..d6a3ca36 --- /dev/null +++ b/desktop/angular/src/app/shared/prompt-list/prompt-list.component.ts @@ -0,0 +1,236 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, OnDestroy, OnInit, TrackByFunction } from '@angular/core'; +import { AppProfile, AppProfileService, deepClone, setAppSetting } from '@safing/portmaster-api'; +import { combineLatest, forkJoin, Observable, Subscription } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { Action, ConnectionPrompt, NotificationsService, NotificationType } from 'src/app/services'; +import { moveInOutAnimation, moveInOutListAnimation } from 'src/app/shared/animations'; +import { ParsedDomain, parseDomain } from 'src/app/shared/utils'; +import { ActionIndicatorService } from '../action-indicator'; + +// ExtendedConnectionPrompt extends the normal connection prompt +// with parsed domain information. +interface ExtendedConnectionPrompt extends ConnectionPrompt, ParsedDomain { } + +// ProfilePrompts extends an application profile with prompt +// information mainly used for paginagtion. +interface ProfilePrompts extends AppProfile { + promptsLimited: ExtendedConnectionPrompt[]; + prompts: ExtendedConnectionPrompt[]; + showAll: boolean; +} + +// Number of prompts to display per application profile +// before we start to paginate the list of prompts. +const PromptLimit = 3; + +@Component({ + selector: 'app-prompt-list', + templateUrl: './prompt-list.component.html', + styleUrls: [ + './prompt-list.component.scss' + ], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + moveInOutAnimation, + moveInOutListAnimation + ] +}) +export class PromptListComponent implements OnInit, OnDestroy { + profiles: ProfilePrompts[] = []; + + /** + * @private + * Sets "empty" class on the host element if no prompts are displayed + */ + @HostBinding('class.empty') + get isEmpty() { + return this.profiles.length === 0; + } + + // Subscription to new prompts and profile updates. + private subscription = Subscription.EMPTY; + + constructor( + private changeDetectorRef: ChangeDetectorRef, + private profileService: AppProfileService, + public notifService: NotificationsService, + public uai: ActionIndicatorService + ) { } + + trackPrompts: TrackByFunction = this.notifService.trackBy; + + ngOnInit() { + // filter the stream of all notifications to only emit + // prompts that are used by the privacy filter (filter:prompt prefix). + const prompts$: Observable = this.notifService + .new$ + .pipe( + map(notifs => notifs.filter(notif => { + return notif.Type === NotificationType.Prompt && + notif.EventID.startsWith("filter:prompt"); + })), + ); + + // each time the notification list is emitted make sure we have an + // up-to-date copy of the linked application profile as well. + const profiles$ = prompts$ + .pipe( + switchMap(notifs => { + // collect all profile keys in a distict set so we don't load + // them more that once. + var profileKeys = new Set(); + notifs.forEach(n => profileKeys.add( + this.profileService.getKey(n.EventData!.Profile.Source, n.EventData!.Profile.ID) + )); + // load all of them in parallel + return forkJoin( + Array.from(profileKeys).map(key => this.profileService.getAppProfileFromKey(key)) + ) + }) + ); + + // subscribe to updates on the prompt list and the related profiles. + this.subscription = + combineLatest([ + prompts$, + profiles$, + ]).subscribe(([prompts, profiles]) => { + + let promptsByProfile = new Map(); + + // for each prompt, make an "extended" connection prompt by parsing the + // domain and index them by profile key + prompts.forEach(prompt => { + // prompts must have the connection data attached. If not, ignore it + // here. + if (!prompt.EventData) { + return; + } + + // get the list of prompts indexed by the profile ID. if this is + // the first prompt for that profile create a new array and place + // it at the index. + let entries = promptsByProfile.get(prompt.EventData.Profile.ID); + if (!entries) { + entries = []; + promptsByProfile.set(prompt.EventData.Profile.ID, entries); + } + + // Create an "extended" version of the prompt by parsing + // and assigning the domain and subdomain values. + let copy: ExtendedConnectionPrompt = { + ...prompt, + domain: null, + subdomain: null, + } + Object.assign(copy, parseDomain(prompt.EventData.Entity.Domain)) + entries.push(copy) + }); + + // Convert the list of application profiles into a set of ProfilePrompts + // objects that we can use to actually display the prompts with pagination + // applied. + this.profiles = profiles + .filter(profile => !!promptsByProfile.get(profile.ID)) + .map(profile => { + const prompts = promptsByProfile.get(profile.ID)!; + return { + ...profile, + showAll: prompts.length < PromptLimit, + promptsLimited: prompts.slice(0, PromptLimit), + prompts: prompts, + }; + }) + .sort((a, b) => { + if (a.ID > b.ID) { + return 1; + } + if (a.ID < b.ID) { + return -1; + } + return 0; + }); + + this.changeDetectorRef.markForCheck(); + }) + } + + allow(prompt: ConnectionPrompt) { + let allowActions = [ + 'allow-domain-all', + 'allow-serving-ip', + 'allow-ip', + ]; + + for (let i = 0; i < allowActions.length; i++) { + const action = prompt.AvailableActions.find(a => a.ID === allowActions[i]) + if (!!action) { + this.execute(prompt, action); + return; + } + } + } + + block(prompt: ConnectionPrompt) { + let permitActions = [ + 'block-domain-all', + 'block-serving-ip', + 'block-ip', + ]; + + for (let i = 0; i < permitActions.length; i++) { + const action = prompt.AvailableActions.find(a => a.ID === permitActions[i]) + if (!!action) { + this.execute(prompt, action); + return; + } + } + } + + changeDefault(profile: ProfilePrompts, newDefault: 'permit' | 'block') { + + this.profileService + .getAppProfile(profile.Source, profile.ID) + .pipe( + map(rawProfile => { + const copy = deepClone(rawProfile); + setAppSetting(copy.Config || {}, 'filter/defaultAction', newDefault) + + return copy + }), + switchMap(updatedProfile => this.profileService.saveProfile(updatedProfile)), + ) + .subscribe({ + error: (err) => { + this.uai.error('Failed to change App Settings', this.uai.getErrorMessage(err)); + } + }) + + + setAppSetting(profile.Config || {}, 'filter/defaultAction', newDefault) + } + + allowAll(profile: ProfilePrompts) { + profile.prompts.forEach(prompt => this.allow(prompt)); + } + + denyAll(profile: ProfilePrompts) { + profile.prompts.forEach(prompt => this.block(prompt)); + } + + execute(prompt: ConnectionPrompt, action: Action) { + this.notifService.execute(prompt, action) + .subscribe({ + error: console.error, + }); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + /** @private - {@link TrackByFunction} for profile prompts */ + trackProfile(_: number, p: ProfilePrompts) { + return p.ID; + } +} diff --git a/desktop/angular/src/app/shared/security-lock/index.ts b/desktop/angular/src/app/shared/security-lock/index.ts new file mode 100644 index 00000000..71561750 --- /dev/null +++ b/desktop/angular/src/app/shared/security-lock/index.ts @@ -0,0 +1 @@ +export * from './security-lock'; diff --git a/desktop/angular/src/app/shared/security-lock/security-lock.html b/desktop/angular/src/app/shared/security-lock/security-lock.html new file mode 100644 index 00000000..146fb34a --- /dev/null +++ b/desktop/angular/src/app/shared/security-lock/security-lock.html @@ -0,0 +1,25 @@ +
+ + + + + + + + + + +

{{lockLevel?.displayText}}

+ + See Notifications + +
+
diff --git a/desktop/angular/src/app/shared/security-lock/security-lock.scss b/desktop/angular/src/app/shared/security-lock/security-lock.scss new file mode 100644 index 00000000..12533ded --- /dev/null +++ b/desktop/angular/src/app/shared/security-lock/security-lock.scss @@ -0,0 +1,120 @@ +svg.shield { + width: 100%; + max-width: 7.25rem; + + transform: scale(0.95); + + path { + top: 0px; + left: 0px; + transform-origin: center center; + } + + .shield-one { + transform: scale(.62); + } + + .shield-two { + animation-delay: -1.2s; + opacity: .6; + transform: scale(.8); + } + + .shield-three { + animation-delay: -2.5s; + opacity: .4; + transform: scale(1); + } + + &.text-green-300 { + filter: saturate(1.4); + + .shield-one { + fill: var(--protection-ok-primary); + } + + + .shield-two { + fill: var(--protection-ok-secondary); + } + + .shield-three { + fill: var(--protection-ok-tertiary); + } + + .shield-warn, + .shield-fail { + display: none; + } + + .shield-ok { + stroke: var(--background); + fill: none; + transform: scale(.5); + } + } + + &.text-yellow-300 { + filter: saturate(1.3); + + .shield-one { + fill: var(--protection-warn-primary); + } + + .shield-three, + .shield-two { + //animation: shield-pulse 3s linear; + } + + .shield-two { + fill: var(--protection-warn-secondary); + } + + .shield-three { + fill: var(--protection-warn-tertiary); + } + + .shield-ok, + .shield-fail { + display: none; + } + + .shield-warn { + stroke: var(--background); + fill: none; + transform: scale(.5); + } + } + + &.text-red-300 { + filter: saturate(1.3); + + .shield-one { + fill: var(--protection-fail-primary); + } + + .shield-three, + .shield-two { + //animation: shield-pulse 3s linear reverse; + } + + .shield-two { + fill: var(--protection-fail-secondary); + } + + .shield-three { + fill: var(--protection-fail-tertiary); + } + + .shield-warn, + .shield-ok { + display: none; + } + + .shield-fail { + stroke: var(--background); + fill: none; + transform: scale(.45); + } + } +} diff --git a/desktop/angular/src/app/shared/security-lock/security-lock.ts b/desktop/angular/src/app/shared/security-lock/security-lock.ts new file mode 100644 index 00000000..7b6922c3 --- /dev/null +++ b/desktop/angular/src/app/shared/security-lock/security-lock.ts @@ -0,0 +1,97 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from "@angular/core"; +import { SecurityLevel } from "@safing/portmaster-api"; +import { combineLatest } from "rxjs"; +import { FailureStatus, StatusService, Subsystem } from "src/app/services"; +import { fadeInAnimation, fadeOutAnimation } from "../animations"; + +interface SecurityOption { + level: SecurityLevel; + displayText: string; + class: string; + subText?: string; +} + +@Component({ + selector: 'app-security-lock', + templateUrl: './security-lock.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./security-lock.scss'], + animations: [ + fadeInAnimation, + fadeOutAnimation + ] +}) +export class SecurityLockComponent implements OnInit { + private destroyRef = inject(DestroyRef); + + lockLevel: SecurityOption | null = null; + + /** The display mode for the security lock */ + @Input() + mode: 'small' | 'full' = 'full' + + constructor( + private statusService: StatusService, + private cdr: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + combineLatest([ + this.statusService.status$, + this.statusService.watchSubsystems() + ]) + .subscribe(([status, subsystems]) => { + const activeLevel = status.ActiveSecurityLevel; + const suggestedLevel = status.ThreatMitigationLevel; + + // By default the lock is green and we are "Secure" + this.lockLevel = { + level: SecurityLevel.Normal, + class: 'text-green-300', + displayText: 'Secure', + } + + // Find the highest failure-status reported by any module + // of any subsystem. + const failureStatus = subsystems.reduce((value: FailureStatus, system: Subsystem) => { + if (system.FailureStatus != 0) { + console.log(system); + } + return system.FailureStatus > value + ? system.FailureStatus + : value; + }, FailureStatus.Operational) + + // update the failure level depending on the highest + // failure status. + switch (failureStatus) { + case FailureStatus.Warning: + this.lockLevel = { + level: SecurityLevel.High, + class: 'text-yellow-300', + displayText: 'Warning' + } + break; + case FailureStatus.Error: + this.lockLevel = { + level: SecurityLevel.Extreme, + class: 'text-red-300', + displayText: 'Insecure' + } + break; + } + + // if the auto-pilot would suggest a higher (mitigation) level + // we are always Insecure + if (activeLevel < suggestedLevel) { + this.lockLevel = { + level: SecurityLevel.High, + class: 'high', + displayText: 'Insecure' + } + } + + this.cdr.markForCheck(); + }); + } +} diff --git a/desktop/angular/src/app/shared/spn-account-details/index.ts b/desktop/angular/src/app/shared/spn-account-details/index.ts new file mode 100644 index 00000000..623342d5 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-account-details/index.ts @@ -0,0 +1 @@ +export * from './spn-account-details'; diff --git a/desktop/angular/src/app/shared/spn-account-details/spn-account-details.html b/desktop/angular/src/app/shared/spn-account-details/spn-account-details.html new file mode 100644 index 00000000..3b594057 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-account-details/spn-account-details.html @@ -0,0 +1,101 @@ + +

Account Details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Your Package{{ currentUser.current_plan?.name }}
Access Until{{ currentUser.subscription.ends_at | date:'medium' }}
Your Subscription{{ currentUser.current_plan?.name }}
Status{{ currentUser.subscription.state }}
Next Payment Date + {{ currentUser.subscription.next_billing_date | date:'medium' }} + via + {{ currentUser.subscription.payment_provider }} +
Access Paid Until{{ currentUser.subscription.ends_at | date:'medium' }}
Username{{ currentUser.username }}
Device Name{{ currentUser.device?.name }}
Account State{{ currentUser.state }}
Features{{ currentUser.current_plan?.feature_ids?.join(", ") }}
Device ID{{currentUser.device?.id}}
Logged in Since{{ currentUser.LoggedInAt | date:'medium' }}
+ +
+ + +
+ + + Open Account Page + + + + +
+
+ + diff --git a/desktop/angular/src/app/shared/spn-account-details/spn-account-details.scss b/desktop/angular/src/app/shared/spn-account-details/spn-account-details.scss new file mode 100644 index 00000000..f1c7dc0d --- /dev/null +++ b/desktop/angular/src/app/shared/spn-account-details/spn-account-details.scss @@ -0,0 +1,7 @@ +table tr { + background-color: transparent !important; +} + +table .table-section-start { + border-top: 1.5rem solid transparent; +} diff --git a/desktop/angular/src/app/shared/spn-account-details/spn-account-details.ts b/desktop/angular/src/app/shared/spn-account-details/spn-account-details.ts new file mode 100644 index 00000000..512f6674 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-account-details/spn-account-details.ts @@ -0,0 +1,83 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Inject, OnInit, Optional, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { SPNService, UserProfile } from "@safing/portmaster-api"; +import { SFNG_DIALOG_REF, SfngDialogRef } from "@safing/ui"; +import { catchError, delay, of, tap } from "rxjs"; +import { ActionIndicatorService } from "../action-indicator"; + +@Component({ + templateUrl: './spn-account-details.html', + styleUrls: ['./spn-account-details.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SPNAccountDetailsComponent implements OnInit { + private destroyRef = inject(DestroyRef); + + /** Whether or not we're currently refreshing the user profile from the customer agent */ + refreshing = false; + + /** Whether or not we're still waiting for the user profile to be fetched from the backend */ + loadingProfile = true; + + currentUser: UserProfile | null = null; + + constructor( + private spnService: SPNService, + private cdr: ChangeDetectorRef, + private uai: ActionIndicatorService, + @Inject(SFNG_DIALOG_REF) @Optional() public dialogRef: SfngDialogRef, + ) { } + + /** + * Force a refresh of the local user account + * + * @private - template only + */ + refreshAccount() { + this.refreshing = true; + this.spnService.userProfile(true) + .pipe( + delay(1000), + tap(() => { + this.refreshing = false; + this.cdr.markForCheck(); + }), + ) + .subscribe() + } + + /** + * Logout of your safing account + * + * @private - template only + */ + logout() { + this.spnService.logout() + .pipe(tap(() => this.dialogRef?.close())) + .subscribe(this.uai.httpObserver('SPN Logout', 'SPN Logout')) + } + + ngOnInit(): void { + this.loadingProfile = false; + this.spnService.profile$ + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError(err => of(null)), + ) + .subscribe({ + next: (profile) => { + this.loadingProfile = false; + this.currentUser = profile || null; + + this.cdr.markForCheck(); + }, + complete: () => { + // Database entry deletion will complete the observer. + this.loadingProfile = false; + this.currentUser = null; + + this.cdr.markForCheck(); + }, + }) + } +} diff --git a/desktop/angular/src/app/shared/spn-login/index.ts b/desktop/angular/src/app/shared/spn-login/index.ts new file mode 100644 index 00000000..25850856 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-login/index.ts @@ -0,0 +1 @@ +export * from './spn-login'; diff --git a/desktop/angular/src/app/shared/spn-login/spn-login.html b/desktop/angular/src/app/shared/spn-login/spn-login.html new file mode 100644 index 00000000..8f59d86c --- /dev/null +++ b/desktop/angular/src/app/shared/spn-login/spn-login.html @@ -0,0 +1,70 @@ + +
+

+
+ + + + + + + + + + + + + + + + +
+ + Safing Account Login + + Unlock powerful features. + + +

+ + +
+ You have been logged out by the account server. +
+ Please check your account. +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
diff --git a/desktop/angular/src/app/shared/spn-login/spn-login.scss b/desktop/angular/src/app/shared/spn-login/spn-login.scss new file mode 100644 index 00000000..232d51ee --- /dev/null +++ b/desktop/angular/src/app/shared/spn-login/spn-login.scss @@ -0,0 +1,53 @@ +:host { + display: block; + width: 100%; +} + +.custom-form-input { + background: none; + @apply border-0 border-b border-buttons-light text-secondary font-medium px-0; + + &:active, + &:focus { + background: none; + } +} + +.logo-image { + @apply w-16 absolute; +} + +svg.logo-image { + animation-timing-function: cubic-bezier(0.445, 0.05, 0.55, 0.95); +} + +.spin { + animation-name: spin; + animation-duration: 3500ms; + animation-iteration-count: infinite; + animation-timing-function: linear; +} + +.reverse { + animation-name: spin-reverse; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + 0% { + transform: rotate(360deg); + } + + 100% { + transform: rotate(0deg); + } +} diff --git a/desktop/angular/src/app/shared/spn-login/spn-login.ts b/desktop/angular/src/app/shared/spn-login/spn-login.ts new file mode 100644 index 00000000..a5ae4172 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-login/spn-login.ts @@ -0,0 +1,70 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { SPNService, UserProfile } from "@safing/portmaster-api"; +import { catchError, finalize, of } from "rxjs"; +import { ActionIndicatorService } from "../action-indicator"; + +@Component({ + selector: 'app-spn-login', + templateUrl: './spn-login.html', + styleUrls: ['./spn-login.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SPNLoginComponent implements OnInit { + private destroyRef = inject(DestroyRef); + + /** The current user profile if the user is already logged in */ + profile: UserProfile | null = null; + + /** The value of the username text box */ + username: string = ''; + + /** The value of the password text box */ + password: string = ''; + + @Input() + set forcedLogout(v: any) { + this._forcedLogout = coerceBooleanProperty(v); + } + get forcedLogout() { return this._forcedLogout } + private _forcedLogout = false; + + constructor( + private spnService: SPNService, + private uai: ActionIndicatorService, + private cdr: ChangeDetectorRef + ) { } + + login(): void { + if (!this.username || !this.password) { + return; + } + + this.spnService.login({ + username: this.username, + password: this.password + }) + .pipe(finalize(() => { + this.password = ''; + })) + .subscribe(this.uai.httpObserver('SPN Login', 'SPN Login')) + } + + ngOnInit(): void { + this.spnService.profile$ + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError(() => of(null)) + ) + .subscribe(profile => { + this.profile = profile || null; + + if (!!this.profile) { + this.username = this.profile.username; + } + + this.cdr.markForCheck(); + }); + } +} diff --git a/desktop/angular/src/app/shared/spn-network-status/index.ts b/desktop/angular/src/app/shared/spn-network-status/index.ts new file mode 100644 index 00000000..bfa12d5d --- /dev/null +++ b/desktop/angular/src/app/shared/spn-network-status/index.ts @@ -0,0 +1 @@ +export * from './spn-network-status'; diff --git a/desktop/angular/src/app/shared/spn-network-status/spn-network-status.html b/desktop/angular/src/app/shared/spn-network-status/spn-network-status.html new file mode 100644 index 00000000..83196071 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-network-status/spn-network-status.html @@ -0,0 +1,28 @@ + +
+

Network Status

+ +
+ + Loading Network Status ... +
+
    +
  • +
    + {{ issue.title }} + {{ issue.closed ? 'closed' : 'opened'}} by {{ issue.user }} + {{ + issue.createdAt | timeAgo + }} +
    + +
    + + +
    +
  • +
+
diff --git a/desktop/angular/src/app/shared/spn-network-status/spn-network-status.scss b/desktop/angular/src/app/shared/spn-network-status/spn-network-status.scss new file mode 100644 index 00000000..6c73c8bb --- /dev/null +++ b/desktop/angular/src/app/shared/spn-network-status/spn-network-status.scss @@ -0,0 +1,71 @@ +:host { + @apply block; + min-width: 500px; + width: 50vw; +} + +.issue-list { + width: 100%; + + &, + ul { + overflow-y: auto; + } + + .issue { + position: relative; + display: flex; + flex-direction: column; + cursor: pointer; + @apply mx-2; + + .header { + @apply p-4; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + cursor: pointer; + } + + @apply rounded; + @apply bg-cards-primary; + + .title { + @apply mr-4; + } + + span { + word-break: keep-all; + } + + &:not(:last-child) { + margin-bottom: 0.5rem; + } + + .body { + @apply bg-cards-secondary; + @apply rounded-b; + @apply p-4; + } + + .meta { + @apply text-tertiary; + @apply font-normal; + opacity: .7; + font-size: 95%; + } + + &:hover { + @apply bg-cards-tertiary; + } + + fa-icon { + position: absolute; + right: 1rem; + top: 1rem; + opacity: .8; + cursor: pointer; + } + } +} diff --git a/desktop/angular/src/app/shared/spn-network-status/spn-network-status.ts b/desktop/angular/src/app/shared/spn-network-status/spn-network-status.ts new file mode 100644 index 00000000..131c907f --- /dev/null +++ b/desktop/angular/src/app/shared/spn-network-status/spn-network-status.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, TrackByFunction, inject } from "@angular/core"; +import { map } from "rxjs"; +import { INTEGRATION_SERVICE } from "src/app/integration"; +import { Issue, SupportHubService } from "src/app/services"; + +/** The name of the SPN repository used to filter SPN support hub issues. */ +const SPNRepository = "spn"; + +/** A set of issue labels that are eligible to be displayed */ +const SPNTagSet = new Set(["network status"]) + +interface _Issue extends Issue { + expanded: boolean; +} + +@Component({ + selector: 'app-spn-network-status', + templateUrl: './spn-network-status.html', + styleUrls: ['./spn-network-status.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SPNNetworkStatusComponent implements OnInit { + private readonly integration = inject(INTEGRATION_SERVICE); + private readonly supportHub = inject(SupportHubService); + private readonly cdr = inject(ChangeDetectorRef); + + /** trackIssue is used as a track-by function when rendering SPN issues. */ + trackIssue: TrackByFunction<_Issue> = (_: number, issue: _Issue) => issue.url; + + spnIssues: _Issue[] = []; + + ngOnInit(): void { + this.supportHub.loadIssues() + .pipe( + map(issues => { + return issues + .filter(issue => issue.repository === SPNRepository && issue.labels?.some(l => { + return SPNTagSet.has(l); + })) + .reverse() + }) + ) + .subscribe(issues => { + let spnIssues: _Issue[] = issues + .map(i => { + const existing = this.spnIssues.find(existing => existing.url === i.url); + return { + ...i, + expanded: existing !== undefined ? existing.expanded : false + } + }) + this.spnIssues = spnIssues; + this.cdr.markForCheck(); + }) + } + + /** + * Open a github issue in a new tab/window + * + * @private - template only + */ + openIssue(issue: Issue) { + this.integration.openExternal(issue.url); + } +} diff --git a/desktop/angular/src/app/shared/spn-status/index.ts b/desktop/angular/src/app/shared/spn-status/index.ts new file mode 100644 index 00000000..a996c3cf --- /dev/null +++ b/desktop/angular/src/app/shared/spn-status/index.ts @@ -0,0 +1 @@ +export * from './spn-status'; diff --git a/desktop/angular/src/app/shared/spn-status/spn-status.html b/desktop/angular/src/app/shared/spn-status/spn-status.html new file mode 100644 index 00000000..84006d46 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-status/spn-status.html @@ -0,0 +1,54 @@ +
+ + + +
+

SPN

+ + + + Increase privacy protection + + + Failed to connect + + + Connecting to the SPN ... + + + You're protected + + + + + Home: {{ spnStatus?.ConnectedIP }} via {{ spnStatus?.ConnectedTransport}} + + + + + +
+ +
+
+
+
+ Identities + {{ identities }} +
+
+
diff --git a/desktop/angular/src/app/shared/spn-status/spn-status.ts b/desktop/angular/src/app/shared/spn-status/spn-status.ts new file mode 100644 index 00000000..5cc26478 --- /dev/null +++ b/desktop/angular/src/app/shared/spn-status/spn-status.ts @@ -0,0 +1,128 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from '@angular/router'; +import { BoolSetting, ChartResult, ConfigService, FeatureID, Netquery, SPNService, SPNStatus, UserProfile } from "@safing/portmaster-api"; +import { SfngDialogService } from '@safing/ui'; +import { catchError, forkJoin, interval, of, startWith, switchMap } from "rxjs"; +import { fadeInAnimation, fadeOutAnimation } from "../animations"; +import { SPNAccountDetailsComponent } from '../spn-account-details'; + +@Component({ + selector: 'app-spn-status', + templateUrl: './spn-status.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeInAnimation, + fadeOutAnimation, + ] +}) +export class SPNStatusComponent implements OnInit { + private destroyRef = inject(DestroyRef); + + /** Whether or not the SPN is currently enabled */ + spnEnabled = false; + + /** The chart data for the SPN connection chart */ + spnConnChart: ChartResult[] = []; + + /** The current amount of SPN identities used */ + identities: number = 0; + + /** The current SPN user profile */ + profile: UserProfile | null = null; + + /** The current status of the SPN module */ + spnStatus: SPNStatus | null = null; + + /** Returns whether or not the current package has the SPN feature */ + get packageHasSPN() { + return this.profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) + } + + constructor( + private configService: ConfigService, + private spnService: SPNService, + private netquery: Netquery, + private cdr: ChangeDetectorRef, + private router: Router, + private activeRoute: ActivatedRoute, + private dialog: SfngDialogService + ) { } + + ngOnInit(): void { + this.spnService + .profile$ + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError(() => of(null)) + ) + .subscribe(profile => { + this.profile = profile || null; + + this.cdr.markForCheck(); + }); + + this.spnService.status$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(status => { + this.spnStatus = status; + + this.cdr.markForCheck(); + }) + + this.configService.watch("spn/enable") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(value => { + this.spnEnabled = value; + + // If the user disabled the SPN clear the connection chart + // as well. + if (!this.spnEnabled) { + this.spnConnChart = []; + } + + this.cdr.markForCheck(); + }); + + interval(5000) + .pipe( + startWith(-1), + takeUntilDestroyed(this.destroyRef), + switchMap(() => forkJoin({ + chart: this.netquery.activeConnectionChart({ tunneled: { $eq: true } }), + identities: this.netquery.query({ + query: { tunneled: { $eq: true }, exit_node: { $ne: "" } }, + groupBy: ['exit_node'], + select: [ + 'exit_node', + { $count: { field: '*', as: 'totalCount' } } + ] + }, 'spn-status-get-connections-count-per-exit-node') + })) + ) + .subscribe(data => { + this.spnConnChart = data.chart; + this.identities = data.identities.length; + + this.cdr.markForCheck(); + }) + } + + openOrLogin() { + if (this.activeRoute.snapshot.firstChild?.url[0]?.path === "spn") { + this.dialog.create(SPNAccountDetailsComponent, { + autoclose: true, + backdrop: 'light' + }) + + return + } + + this.router.navigate(['/spn']) + } + + setSPNEnabled(v: boolean) { + this.configService.save(`spn/enable`, v) + .subscribe(); + } +} diff --git a/desktop/angular/src/app/shared/status-pilot/index.ts b/desktop/angular/src/app/shared/status-pilot/index.ts new file mode 100644 index 00000000..1ec75e5b --- /dev/null +++ b/desktop/angular/src/app/shared/status-pilot/index.ts @@ -0,0 +1 @@ +export { StatusPilotComponent as PilotWidgetComponent } from "./pilot-widget"; diff --git a/desktop/angular/src/app/shared/status-pilot/pilot-widget.html b/desktop/angular/src/app/shared/status-pilot/pilot-widget.html new file mode 100644 index 00000000..52e41fbb --- /dev/null +++ b/desktop/angular/src/app/shared/status-pilot/pilot-widget.html @@ -0,0 +1,57 @@ + + + +
+ {{ activeLevelText }} + + + + + +
+ + +
+
+ + + + + + Auto Detect + + + + + + Manual + + + +
+ + +
+
+ + {{opt.displayText}} + + + {{opt.subText || ''}} + + +
+
+
+
+
diff --git a/desktop/angular/src/app/shared/status-pilot/pilot-widget.scss b/desktop/angular/src/app/shared/status-pilot/pilot-widget.scss new file mode 100644 index 00000000..3f1bcae7 --- /dev/null +++ b/desktop/angular/src/app/shared/status-pilot/pilot-widget.scss @@ -0,0 +1,208 @@ +:host { + overflow: visible; + position: relative; + display: flex; + justify-content: space-between; + background: none; + user-select: none; + align-items: center; + justify-content: space-evenly; + flex-direction: column; + + + @keyframes shield-pulse { + 0% { + transform: scale(.62); + opacity: 1; + } + + 100% { + transform: scale(1.1); + opacity: 0; + } + } + + @keyframes pulse-opacity { + 0% { + opacity: 0.1; + } + + 100% { + opacity: 1; + } + } +} + +.spn-status { + background-color: var(--info-blue); + border-radius: 100%; + display: flex; + align-items: center; + justify-content: center; + opacity: 1 !important; + padding: 0.2rem; + transform: scale(0.8); + position: absolute; + bottom: 42px; + right: 18px; + + &.connected { + background-color: theme('colors.info.blue'); + } + + &.connecting, + &.failed { + background-color: theme('colors.info.gray'); + } + + svg { + stroke: white; + } +} + +::ng-deep { + + .network-rating-level-list { + @apply p-3 rounded; + + flex-grow: 1; + + label { + opacity: 0.6; + font-size: 0.75rem; + font-weight: 500; + } + + div.rate-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 0 0.3rem 0; + margin-right: 0.11rem; + + .auto-detect { + height: 5px; + width: 5px; + margin-right: 10px; + margin-bottom: 1px; + background-color: #4995f3; + border-radius: 50%; + display: inline-block; + } + } + + &:not(.auto-pilot) { + div.level.selected { + div { + background-color: #292929; + } + + &:after { + transition: none; + opacity: 0 !important; + } + } + } + + div.level { + position: relative; + padding: 2px; + margin-top: 0.155rem; + cursor: pointer; + overflow: hidden; + z-index: 1; + + fa-icon[icon*="question-circle"] { + float: right; + } + + &:after { + transition: all cubic-bezier(0.19, 1, 0.82, 1) .2s; + @apply rounded; + content: ""; + filter: saturate(1.3); + background-image: linear-gradient(90deg, #226ab79f 0%, rgba(2, 0, 36, 0) 45%); + transform: translateX(100%); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + opacity: 0; + } + + div { + background-color: #202020; + border-radius: 2px; + padding: 9px 17px 10px 18px; + display: block; + opacity: 0.55; + + span { + font-size: 0.725rem; + font-weight: 400; + } + + .situation { + @apply text-tertiary; + @apply ml-2; + font-size: 0.6rem; + font-weight: 600; + } + + svg.help { + width: 0.95rem; + float: right; + padding: 0; + margin: 0; + margin-top: 1.5px; + + .inner { + stroke: var(--text-secondary); + } + + &:hover, + &:active { + .inner { + stroke: var(--text-primary); + } + } + } + } + + &.selected { + div { + background-color: #292929; + opacity: 1; + } + } + + &.selected, + &.suggested { + &:after { + transform: translateX(0%); + opacity: 1; + } + + } + + &.suggested { + &:after { + animation: pulse-opacity 1s ease-in-out infinite alternate; + } + } + + &:hover, + &:active { + div { + opacity: 1; + + span { + opacity: 1; + } + } + } + } + } +} diff --git a/desktop/angular/src/app/shared/status-pilot/pilot-widget.ts b/desktop/angular/src/app/shared/status-pilot/pilot-widget.ts new file mode 100644 index 00000000..4fa01dd6 --- /dev/null +++ b/desktop/angular/src/app/shared/status-pilot/pilot-widget.ts @@ -0,0 +1,115 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ConfigService, SecurityLevel } from '@safing/portmaster-api'; +import { combineLatest } from 'rxjs'; +import { FailureStatus, StatusService, Subsystem } from 'src/app/services'; + +interface SecurityOption { + level: SecurityLevel; + displayText: string; + class: string; + subText?: string; +} + +@Component({ + selector: 'app-status-pilot', + templateUrl: './pilot-widget.html', + styleUrls: [ + './pilot-widget.scss' + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StatusPilotComponent implements OnInit { + activeLevel: SecurityLevel = SecurityLevel.Off; + selectedLevel: SecurityLevel = SecurityLevel.Off; + suggestedLevel: SecurityLevel = SecurityLevel.Off; + activeOption: SecurityOption | null = null; + selectedOption: SecurityOption | null = null; + + mode: 'auto' | 'manual' = 'auto'; + + get activeLevelText() { + return this.options.find(opt => opt.level === this.activeLevel)?.displayText || ''; + } + + readonly options: SecurityOption[] = [ + { + level: SecurityLevel.Normal, + displayText: 'Trusted', + class: 'low', + subText: 'Home Network' + }, + { + level: SecurityLevel.High, + displayText: 'Untrusted', + class: 'medium', + subText: 'Public Network' + }, + { + level: SecurityLevel.Extreme, + displayText: 'Danger', + class: 'high', + subText: 'Hacked Network' + }, + ]; + + get networkRatingEnabled$() { return this.configService.networkRatingEnabled$ } + + constructor( + private statusService: StatusService, + private changeDetectorRef: ChangeDetectorRef, + private configService: ConfigService, + ) { } + + ngOnInit() { + + combineLatest([ + this.statusService.status$, + this.statusService.watchSubsystems() + ]) + .subscribe(([status, subsystems]) => { + this.activeLevel = status.ActiveSecurityLevel; + this.selectedLevel = status.SelectedSecurityLevel; + this.suggestedLevel = status.ThreatMitigationLevel; + + if (this.selectedLevel === SecurityLevel.Off) { + this.mode = 'auto'; + } else { + this.mode = 'manual'; + } + + this.selectedOption = this.options.find(opt => opt.level === this.selectedLevel) || null; + this.activeOption = this.options.find(opt => opt.level === this.activeLevel) || null; + + // Find the highest failure-status reported by any module + // of any subsystem. + const failureStatus = subsystems.reduce((value: FailureStatus, system: Subsystem) => { + if (system.FailureStatus != 0) { + console.log(system); + } + return system.FailureStatus > value + ? system.FailureStatus + : value; + }, FailureStatus.Operational) + + this.changeDetectorRef.markForCheck(); + }); + } + + updateMode(mode: 'auto' | 'manual') { + this.mode = mode; + + if (mode === 'auto') { + this.selectLevel(SecurityLevel.Off); + } else { + this.selectLevel(this.activeLevel); + } + } + + selectLevel(level: SecurityLevel) { + if (this.mode === 'auto' && level !== SecurityLevel.Off) { + this.mode = 'manual'; + } + + this.statusService.selectLevel(level).subscribe(); + } +} diff --git a/desktop/angular/src/app/shared/text-placeholder/index.ts b/desktop/angular/src/app/shared/text-placeholder/index.ts new file mode 100644 index 00000000..8d04c94a --- /dev/null +++ b/desktop/angular/src/app/shared/text-placeholder/index.ts @@ -0,0 +1 @@ +export { PlaceholderComponent } from './placeholder'; diff --git a/desktop/angular/src/app/shared/text-placeholder/placeholder.scss b/desktop/angular/src/app/shared/text-placeholder/placeholder.scss new file mode 100644 index 00000000..88140deb --- /dev/null +++ b/desktop/angular/src/app/shared/text-placeholder/placeholder.scss @@ -0,0 +1,32 @@ +.text-placeholder { + display : inline-block; + height : 0.75rem; + position: relative; + + .background { + @apply rounded; + opacity : 0.8; + animation-duration : 6s; + animation-fill-mode : forwards; + animation-iteration-count: infinite; + animation-name : placeHolderShimmer; + animation-timing-function: linear; + background : linear-gradient(to right, #4b4b4b 8%, #5a5a5a 18%, #4b4b4b 33%); + position : absolute; + backface-visibility : hidden; + left : 0; + right : 0; + top : 2px; + bottom : 0; + } +} + +@keyframes placeHolderShimmer { + 0% { + background-position: 0px 0; + } + + 100% { + background-position: 100em 0; + } +} diff --git a/desktop/angular/src/app/shared/text-placeholder/placeholder.ts b/desktop/angular/src/app/shared/text-placeholder/placeholder.ts new file mode 100644 index 00000000..0b9797a3 --- /dev/null +++ b/desktop/angular/src/app/shared/text-placeholder/placeholder.ts @@ -0,0 +1,61 @@ +import { AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input } from '@angular/core'; + +@Component({ + selector: 'app-text-placeholder', + template: ` + +
+
+ + `, + styleUrls: ['./placeholder.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaceholderComponent implements AfterContentChecked { + @Input() + set width(v: string | number) { + if (typeof v === 'number') { + this._width = `${v}px`; + return + } + + switch (v) { + case 'small': + this._width = '5rem'; + break; + case 'medium': + this._width = '10rem'; + break; + case 'large': + this._width = '15rem'; + break + default: + this._width = v; + } + } + get width() { return this._width; } + private _width: string = '10rem'; + + @Input() + mode: 'auto' | 'input' = 'auto'; + + @Input() + loading = true; + + constructor( + private elementRef: ElementRef, + private changeDetector: ChangeDetectorRef, + ) { } + + ngAfterContentChecked() { + if (this.mode === 'input') { + return; + } + + const show = this.elementRef.nativeElement.innerText === ''; + if (this.loading != show) { + this.loading = show; + this.changeDetector.detectChanges(); + } + } +} diff --git a/desktop/angular/src/app/shared/utils.ts b/desktop/angular/src/app/shared/utils.ts new file mode 100644 index 00000000..c36caa07 --- /dev/null +++ b/desktop/angular/src/app/shared/utils.ts @@ -0,0 +1,76 @@ +import { parse } from 'psl'; + +export interface ParsedDomain { + domain: string | null; + subdomain: string | null; +} +export function parseDomain(scope: string): ParsedDomain { + // Due to https://github.com/lupomontero/psl/issues/185 + // parse will throw an error for service-discovery lookups + // so make sure we split them apart. + const domainParts = scope.split(".") + const lastUnderscorePart = domainParts.length - [...domainParts].reverse().findIndex(dom => dom.startsWith("_")) + let result: ParsedDomain = { + domain: null, + subdomain: null, + } + + let cleanedDomain = scope; + let removedPrefix = ''; + if (lastUnderscorePart <= domainParts.length) { + removedPrefix = domainParts.slice(0, lastUnderscorePart).join('.') + cleanedDomain = domainParts.slice(lastUnderscorePart).join('.') + } + + const parsed = parse(cleanedDomain); + if ('listed' in parsed) { + result.domain = parsed.domain || scope; + result.subdomain = removedPrefix; + if (!!parsed.subdomain) { + if (removedPrefix != '') { + result.subdomain += '.'; + } + result.subdomain += parsed.subdomain; + } + } + + return result +} + +export function binarySearch(array: T[], what: T, sortFunc: (a: T, b: T) => number): number { + let l = 0; + let h = array.length - 1; + let currentIndex: number = 0; + + while (l <= h) { + currentIndex = (l + h) >>> 1; + const result = sortFunc(what, array[currentIndex]); + if (result < 0) { + l = currentIndex + 1; + } else if (result > 0) { + h = currentIndex - 1; + } else { + return currentIndex; + } + } + return ~currentIndex; +} + +export function binaryInsert(array: T[], what: T, sortFunc: (a: T, b: T) => number, duplicate = false): number { + let idx = binarySearch(array, what, sortFunc); + if (idx >= 0) { + if (!duplicate) { + return idx; + } + } else { + // if `what` is not part of `array` than index is the bitwise complement + // of the expected index in array. + idx = ~idx; + } + array.splice(idx, 0, what) + return idx; +} + +export function objKeys(obj: T): (keyof T)[] { + return Object.keys(obj) as any; +} diff --git a/desktop/angular/src/assets b/desktop/angular/src/assets new file mode 120000 index 00000000..ec2e4be2 --- /dev/null +++ b/desktop/angular/src/assets @@ -0,0 +1 @@ +../assets \ No newline at end of file diff --git a/desktop/angular/src/electron-app.d.ts b/desktop/angular/src/electron-app.d.ts new file mode 100644 index 00000000..febea42b --- /dev/null +++ b/desktop/angular/src/electron-app.d.ts @@ -0,0 +1,41 @@ +declare global { + interface Window { + app: AppAPI; + } +} + +export class AppAPI { + /** Returns the current platform */ + getPlatform(): Promise; + + /** The installation directory of portmaster. */ + getInstallDir(): Promise; + + /** + * Open an URL or path using an external application. + * + * @param pathOrUrl The path or URL to open. + */ + openExternal(pathOrUrl: string): Promise; + + /** + * Creates a new URL with the file:// scheme. Works + * on any platform. + * + * @param path The path for the file URL. + */ + createFileURL(path: string): Promise; + + /** + * Returns a dataURL for the icon that is used to represent + * the path on this platform. + * This method only works on windows for now. On all other + * platforms an empty string is returned. + * + * @param path The path the the binary + */ + getFileIcon(path: string): Promise; + + /** Exit the electron appliction. */ + exitApp(): Promise; +} diff --git a/desktop/angular/src/environments/environment.prod.ts b/desktop/angular/src/environments/environment.prod.ts new file mode 100644 index 00000000..8cdffedb --- /dev/null +++ b/desktop/angular/src/environments/environment.prod.ts @@ -0,0 +1,13 @@ +export const environment = new class { + readonly supportHub = "https://support.safing.io" + readonly production = true; + + get httpAPI() { + return `http://${window.location.host}/api` + } + + get portAPI() { + const result = `ws://${window.location.host}/api/database/v1`; + return result; + } +} \ No newline at end of file diff --git a/desktop/angular/src/environments/environment.ts b/desktop/angular/src/environments/environment.ts new file mode 100644 index 00000000..5ef6df25 --- /dev/null +++ b/desktop/angular/src/environments/environment.ts @@ -0,0 +1,19 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false, + portAPI: "ws://127.0.0.1:817/api/database/v1", + httpAPI: "http://127.0.0.1:817/api", + supportHub: "https://support.safing.io" +}; + +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +import 'zone.js/dist/zone-error'; // Included with Angular CLI. diff --git a/desktop/angular/src/i18n/helptexts.yaml b/desktop/angular/src/i18n/helptexts.yaml new file mode 100644 index 00000000..e00b6023 --- /dev/null +++ b/desktop/angular/src/i18n/helptexts.yaml @@ -0,0 +1,370 @@ +########### +### Example + +myKey: + title: Tipup Example + content: | + This is the Markdown formatted content. + + This is a super cool, new feature that you will love! + It even supports markdown features like: + - order lists + - with multiple items + + And :rocket: emojis + + ### :tada: :facepalm: + url: https://docs.safing.io/?source=Portmaster + urlText: Show me! + nextKey: navMonitor + +############## +### Navigation + +introTipup: + title: Hey there! + content: | + Thanks for installing the Portmaster. + +intro: + title: Portmaster Tips + content: | + Open tips to learn how the Portmaster work. + + Tips like this one are found throughout the Portmaster. With some tips you can tour an element or a feature, like this: + nextKey: navShield + +navShield: + title: Status Shield & Dashboard + content: | + The shield gives you a high level overview of Portmaster's status. If turns any other color than green, look for a notification that tells you what is going on. + + __Click the shield in order to open the dashboard.__ + nextKey: navMonitor + +navMonitor: + title: Network Monitor + content: | + Oversee and investigate everything happening on your device. + nextKey: navApps + buttons: + - name: Take the tour + action: + Type: open-page + Payload: monitor + nextKey: networkMonitor + +navApps: + title: Per-App Settings + content: | + Configure per-app settings which override the global default. + nextKey: navMap + buttons: + - name: Take the tour + action: + Type: open-page + Payload: apps + nextKey: appsTitle + +navMap: + title: SPN Map + content: | + View the SPN map and see how your connections are routed. + nextKey: navSettings + +navSettings: + title: Global Settings + content: | + Configure global Portmaster settings. + nextKey: navSupport + buttons: + - name: Take the tour + action: + Type: open-page + Payload: settings + nextKey: globalSettings + +navSupport: + title: Get Help + content: | + Report a bug, contact support or view the extended Portmaster docs. + nextKey: navTools + buttons: + - name: Open Page + action: + Type: open-page + Payload: support + +navTools: + title: Version and Tools + content: | + View the Portmaster's version and use special actions and tools. + nextKey: navPower + +navPower: + title: Shutdown and Restart + content: | + Shutdown or Restart Portmaster. + nextKey: uiMode + +uiMode: + title: UI Mode + content: | + Quickly change the amount of settings and information shown. + + Hidden settings are still in effect. After closing the User Interface it changes back to the default. + buttons: + - name: Change Default UI Mode + action: + Type: "open-setting" + Payload: + Key: "core/expertiseLevel" + +############ +### Sidedash + +pilot-widget: + title: Portmaster Status + content: | + This shield shows you the current state of the Portmaster: + + - 🟢 all is well + - 🟡 something is off, please investigate + - 🔴 dangerous condition, respond immediately + + This color code is also displayed as part of the icon in the system tray. + +pilot-widget-NetworkRating: + title: Network Rating + content: | + Control your privacy even when connecting to new networks. + + In the Portmaster you configure settings to be active in one environment but not in the other, like allowing sensitive connections at home but not at the public library. + + The only thing you have to do is to change the network rating whenever you connect to a different network. + nextKey: pilot-widget-NetworkRating-Trusted + +pilot-widget-NetworkRating-Trusted: + title: "Network Rating: Trusted" + content: | + You trust the current network to be secure and protect you. + + Examples: + - your home network + - network of a trusted friends + nextKey: pilot-widget-NetworkRating-Untrusted + +pilot-widget-NetworkRating-Untrusted: + title: "Network Rating: Untrusted" + content: | + You do not trust the current network and question if it will keep you secure and private. + + Examples: + - public WiFi of a coffeeshop, a library, a train, a hotel, ... + - network of a non-tech-savvy relative + nextKey: pilot-widget-NetworkRating-Danger + +pilot-widget-NetworkRating-Danger: + title: "Network Rating: Danger" + content: | + You think that the current network is hacked or otherwise hostile towards you. + + Examples: + - something suspicious is going on in your home network + + _Note: In the "Danger" rating the Portmaster will become very protective. This might break functionality of apps or render them useless._ + +broadcast-info: + title: Broadcast Notifications + content: | + Broadcast Notifications are public messages downloaded by the Portmaster when checking for updates. + + The Portmaster then locally decides which messages should be displayed. + url: https://github.com/safing/portmaster/issues/703 + urlText: Learn More + +# TODO +# prompt-widget: +# title: Prompts +# content: | +# This is where you can more easily control the +# connections for the specific app for the time being. + +# How to use? In App settings, search for "Default Action" +# and set it to "Prompt". + +# Note: Don't set the "Prompt" setting in your browser, +# you will be spammed. You have been warned. +# nextKey: notification-widget + +# TODO +# notification-widget: +# title: Notifications +# content: | +# This informs you with what's going on with portmaster. +# Ie, Updates, Errors, Warring etc + +############# +### Dashboard + +dashboardIntro: + title: Dashboard + content: | + The Dashboard gives you a first overview of Portmaster's active features and what is happening on your device. + + Unless noted otherwise, all graphs and statistics shown are based on what Portmaster has seen in the last 10 minutes and are refreshed every 10 seconds. + +######################## +### Network Monitor Page + +networkMonitor: + title: Network Activity + content: | + Oversee everything happening on your device. + + Look at all network connections of all applications and processes that were active in the last 10 minutes. Click on any app or process to investigate further. + +# TODO: Wait for overview to be more useful. +# networkMonitor-Overview: +# title: Monitor Overview +# content: | +# This is just a placeholder for the meantime, but this is +# just the Network Monitor with 3 stats on it. + +# TODO: Wait for revamp of status indication. +# networkMonitor-App: +# title: App Activity +# content: | +# There are 3 colours. Ie, Green, Red, Gray. + +# Allowed(Green) +# The colour green shows that all the connections are allowed in +# the app. + +# Blocked(Red) +# The colour red shows that all the connections are blocked in +# the app. + +# Allowed/Blocked(Gray) +# The colour gray shows that some connections are +# allowed and blocked in the app. + +networkMonitor-App-Focus-connection-history: + title: Network Activity + content: | + Monitor connections as they happen. Click on any connection to view details and to take action. +

+ + + 2k+ + + + + + Status Summary +

+ Grouped connections have a colored bar showing the total amount of connections, + as well as the percentage between allowed (green) and blocked/failed connections (grey). +

+ An individual connection has three states:
+ Allowed
+ Blocked
+ Failed
+ + If the circle is full, your _current_ settings allowed or blocked the connection.
+ If the circle is empty, _previous_ settings allowed or blocked the connection. + Your current settings could decide differently. + +######################## +### Global Settings Page + +globalSettings: + title: Global Settings + content: | + Here you can set system-wide preferences and configure default rules for all your apps and connections. + + It is easy to create a stricter global ruleset and then create exceptions in the app settings, which override the global default. + +######################### +### Per-App Settings Page + +appsTitle: + title: Application Overview + content: | + All applications or processes that the Portmaster saw being active on the network are listed and can be configured here. + + Apps are categorized and only appear once: + + - **Active:** apps that are currently active and visible in the Network Monitor + - **Recently Used:** apps that were active some time within the last week + - **Recently Edited:** apps whose settings were edited within the last week + - **Other:** all other apps + +appSettings: + title: App Settings + content: | + Here you can configure app-specific settings which override the global settings. + + It is easy to create a stricter global ruleset and then create exceptions in the app settings, which override the global default. + nextKey: appSettings-Filter + +appSettings-Filter: + title: Display Mode + content: | + Quickly change what settings are displayed: + + **View Active:**
+ Only show app-specific settings which override the global default. + + **View All:**
+ Show all settings. App-specific settings which override the global default are highlighted. + +appSettings-QuickSettings: + title: Quickly Change the Most Important Settings + content: | + __Block Connections__ + + Set the default action for when nothing else allows or blocks an outgoing connection. + + When other settings might overwrite this, a yellow dot next to the toggle will inform you of possible exceptions. + + __SPN__ + + Quickly enable or disable SPN for this app. + + When other settings might overwrite this, a yellow dot next to the toggle will inform you of possible exceptions. + + __Keep History__ + + Save connections in a database (on disk) in order to view and search them later. + + Changes might take a couple minutes to apply to all connections. + +######################### +### Support Page + +support-page-related-issues: + title: Local Issue Search + content: | + Public issues are only searched for locally so no data leaves your device until you decide so. + + The public GitHub issues are downloaded via our support system to prevent exposure to GitHub. + +######################### +### Configuration Options + +spn: + title: Safing Privacy Network + content: | + The Safing Privacy Network (SPN) is a Portmaster Add-On that protects your identity + and Internet traffic from prying eyes. It spreads your connections over multiple server, + letting you access the Internet from many places at once in order to effectively hide + your tracks. + url: https://safing.io/spn/?source=Portmaster + urlText: Learn More + +########################### +# Process Matching and Fingerprints +process-tags: + title: Process Tags + content: Tags holds special metadata of processes and are gathered by Portmaster. You can use these tags in fingerprints to better match processes, which would otherwise be a lot more difficult or impossible to match correctly. diff --git a/desktop/angular/src/i18n/helptexts.yaml.d.ts b/desktop/angular/src/i18n/helptexts.yaml.d.ts new file mode 100644 index 00000000..979498e3 --- /dev/null +++ b/desktop/angular/src/i18n/helptexts.yaml.d.ts @@ -0,0 +1,24 @@ + +declare module 'js-yaml-loader!*' { + import { Action } from "src/app/services/notifications.types"; + export interface Button { + name: string; + action: Action; + nextKey?: string; + } + + export interface TipUp { + title: string; + content: string; + url?: string; + urlText?: string; + buttons?: Button[]; + nextKey?: string; + } + export interface HelpTexts { + [key: string]: TipUp; + } + + const content: HelpTexts; + export default content; +} diff --git a/desktop/angular/src/index.html b/desktop/angular/src/index.html new file mode 100644 index 00000000..8912951b --- /dev/null +++ b/desktop/angular/src/index.html @@ -0,0 +1,34 @@ + + + + + + Portmaster + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/angular/src/main.ts b/desktop/angular/src/main.ts new file mode 100644 index 00000000..2b10a238 --- /dev/null +++ b/desktop/angular/src/main.ts @@ -0,0 +1,94 @@ +import { enableProdMode, importProvidersFrom } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; +import { INTEGRATION_SERVICE, integrationServiceFactory } from './app/integration'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { PromptWidgetComponent } from './app/shared/prompt-list'; +import { PromptEntryPointComponent } from './app/prompt-entrypoint/prompt-entrypoint'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { PortmasterAPIModule } from '@safing/portmaster-api'; +import { NotificationsService } from './app/services'; +import { TauriIntegrationService } from './app/integration/taur-app'; + +if (environment.production) { + enableProdMode(); +} + +if (typeof (CSS as any)['registerProperty'] === 'function') { + (CSS as any).registerProperty({ + name: '--lock-color', + syntax: '*', + inherits: true, + initialValue: '10, 10, 10' + }) +} + +function handleExternalResources(e: Event) { + // get click target + let target: HTMLElement | null = e.target as HTMLElement; + // traverse until we reach an a tag + while (!!target && target.tagName !== "A") { + target = target.parentElement; + } + + if (!!target) { + let href = target.getAttribute("href"); + if (href?.startsWith("blob")) { + return + } + + if (!!href && !href.includes(location.hostname)) { + e.preventDefault(); + + integrationServiceFactory().openExternal(href); + } + } +} + +if (document.addEventListener) { + document.addEventListener("click", handleExternalResources); +} + +// load the font file but make sure to use the slimfix version +// windows. +{ + // we cannot use document.writeXX here as it's not allowed to + // write to Document from an async loaded script. + + let linkTag = document.createElement("link"); + linkTag.rel = "stylesheet"; + linkTag.href = "/assets/vendor/fonts/roboto.css"; + if (navigator.platform.startsWith("Win")) { + linkTag.href = "/assets/vendor/fonts/roboto-slimfix.css" + } + + document.head.appendChild(linkTag); +} + + +if (location.pathname !== "/prompt") { + // bootstrap our normal application + platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); + +} else { + // bootstrap the prompt interface + bootstrapApplication(PromptEntryPointComponent, { + providers: [ + provideHttpClient(), + importProvidersFrom(PortmasterAPIModule.forRoot({ + websocketAPI: "ws://localhost:817/api/database/v1", + httpAPI: "http://localhost:817/api" + })), + NotificationsService, + { + provide: INTEGRATION_SERVICE, + useClass: TauriIntegrationService + } + ], + }) +} + diff --git a/desktop/angular/src/polyfills.ts b/desktop/angular/src/polyfills.ts new file mode 100644 index 00000000..576bf9d7 --- /dev/null +++ b/desktop/angular/src/polyfills.ts @@ -0,0 +1,57 @@ +/*************************************************************************************************** + * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. + */ +import '@angular/localize/init'; +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js/dist/zone'; // Included with Angular CLI. + + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/desktop/angular/src/styles.scss b/desktop/angular/src/styles.scss new file mode 100644 index 00000000..52a47745 --- /dev/null +++ b/desktop/angular/src/styles.scss @@ -0,0 +1,120 @@ +// +// Import our complete theme, order is important! +// +@import 'theme/_colors.scss'; +@import 'theme/_tailwind.scss'; +@import '@angular/cdk/overlay-prebuilt'; +@import 'theme/_button.scss'; +@import 'theme/_drag-n-drop.scss'; +@import 'theme/_inputs.scss'; +@import 'theme/_scroll.scss'; +@import 'theme/_search.scss'; +@import 'theme/_trust-level.scss'; +@import 'theme/_verdict.scss'; +@import 'theme/_typography.scss'; +@import 'theme/_markdown.scss'; +@import 'theme/_card.scss'; +@import 'theme/_breadcrumbs.scss'; +@import 'theme/_dialog.scss'; +@import 'theme/_table.scss'; +@import 'theme/_pill.scss'; + +@import 'safing/ui/theming'; + +*[routerlink] { + cursor: pointer; +} + +.form-field { + display: flex; + justify-content: flext-start; + align-items: center; + + *:not(:last-child) { + @apply mr-1; + } +} + +.sidebar { + @apply bg-background; + height: 100vh; + flex-shrink: 0; + flex-grow: 0; + @apply px-2; + display: flex; + flex-direction: column; + + &.no-scroll { + @apply px-0; + } +} + +.main { + .content { + flex-grow: 1; + @apply pl-12; + @apply pr-16; + @apply mr-4; + overflow: auto; + } + + .header { + display: flex; + width: 100%; + @apply pl-12; + @apply pr-5; + @apply mb-2; + align-items: center; + height: 3rem; + flex-shrink: 0; + + &:first-of-type { + @apply mt-2; + } + + >* { + flex-grow: 1; + margin: 0; + } + + >app-expertise { + flex-grow: 0; + flex-shrink: 0; + } + } +} + +.tableFixHead { + overflow-y: auto; +} + +.tableFixHead thead th { + position: sticky; + top: 0; +} + + +fa-icon.tipup, +fa-icon[icon="question-circle"], +fa-icon[icon="question"] { + max-width: 10px; + max-height: 10px; + opacity: 0.8; + display: inline-block; + font-size: 0.75rem; + color: rgb(250 250 250 / 55%); + margin-left: 3px; + + &:hover { + opacity: unset; + } +} + +.tipup-preview { + transition: all .25s ease-in-out !important; + opacity: 0 !important; + + &.visible { + opacity: 1 !important; + } +} diff --git a/desktop/angular/src/test.ts b/desktop/angular/src/test.ts new file mode 100644 index 00000000..06aa8e41 --- /dev/null +++ b/desktop/angular/src/test.ts @@ -0,0 +1,14 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); diff --git a/desktop/angular/src/theme.less b/desktop/angular/src/theme.less new file mode 100644 index 00000000..64154716 --- /dev/null +++ b/desktop/angular/src/theme.less @@ -0,0 +1,4 @@ + +// Custom Theming for NG-ZORRO +// For more information: https://ng.ant.design/docs/customize-theme/en +@import "../node_modules/ng-zorro-antd/ng-zorro-antd.dark.less"; diff --git a/desktop/angular/src/theme/_breadcrumbs.scss b/desktop/angular/src/theme/_breadcrumbs.scss new file mode 100644 index 00000000..42df118b --- /dev/null +++ b/desktop/angular/src/theme/_breadcrumbs.scss @@ -0,0 +1,20 @@ +h4.breadcrumbs { + * { + margin-left : 0.125rem; + margin-right: 0.125rem; + } + + span { + outline: none; + @apply text-secondary; + + &:hover { + @apply text-primary; + text-decoration: underline; + } + + &:last-of-type { + @apply text-primary; + } + } +} diff --git a/desktop/angular/src/theme/_button.scss b/desktop/angular/src/theme/_button.scss new file mode 100644 index 00000000..b1ac1976 --- /dev/null +++ b/desktop/angular/src/theme/_button.scss @@ -0,0 +1,60 @@ +@layer components { + button { + @apply text-xs; + @apply bg-buttons-dark; + @apply p-1; + @apply px-4; + @apply capitalize; + @apply rounded-sm; + @apply font-medium; + @apply focus:underline focus:underline-offset-4; + + user-select: none; + outline: none; + cursor: pointer; + font-size: 0.7rem; + + &.btn-outline { + background: transparent; + opacity: 0.6; + } + + &:hover { + &:not(.outline):not(.bg-blue) { + @apply bg-buttons-light; + } + + opacity: 1; + } + + &:disabled { + @apply cursor-default; + opacity: 0.3; + + &:not(.outline):hover { + @apply bg-buttons-dark; + } + } + + &:active { + @apply bg-buttons-dark; + } + + &:hover, + &:focus, + &:active { + outline: none; + } + } + + .info-circle { + width: 18px; + height: 18px; + display: flex; + justify-content: center; + align-items: center; + font-size: 0.8em; + @apply rounded-full; + @apply bg-buttons-dark; + } +} diff --git a/desktop/angular/src/theme/_card.scss b/desktop/angular/src/theme/_card.scss new file mode 100644 index 00000000..284a4bbe --- /dev/null +++ b/desktop/angular/src/theme/_card.scss @@ -0,0 +1,110 @@ +.card-header { + display : flex; + align-items : center; + cursor : pointer; + outline : none; + justify-content: space-between; + @apply text-xs; + @apply font-medium; + margin-top : 5px; + padding-top : 0.65rem; + padding-bottom : 0.65rem; + padding-left : 0.65rem; + padding-right : 0.65rem; + border-top-left-radius : 4px; + border-top-right-radius: 4px; + background-color : #202020e0; + + &:not(.open) { + border-radius: 4px; + } + + &>*:not(:last-child) { + @apply mr-1; + } + + &>app-icon:not(:last-child) { + @apply mr-2; + } + + &:hover { + background-color: #292929b0; + } + + &.active { + background-color: #303030; + + app-count-indicator { + background-color: #474747; + + div.state { + background-color: #5c5c5c; + + } + } + } + + &>app-icon { + --app-icon-size: 22px; + } + + .card-title { + flex-grow : 1; + overflow : hidden; + white-space : nowrap; + text-overflow: ellipsis; + font-size : 0.7rem; + font-weight : 600; + color : #cacaca; + margin-left : 3px; + + .card-sub-title { + display : block; + font-size : 0.8em; + margin-top: -3px; + @apply text-tertiary; + text-overflow: ellipsis; + overflow : hidden; + } + } + + .card-actions { + @apply mr-2; + + span { + display : inline-block; + text-align: center; + min-width : 5rem; + @apply px-2; + @apply rounded; + @apply text-xs; + + padding-top : 0.1rem; + padding-bottom: 0.1rem; + + // TODO(ppacher): this is actually a "toggle-switch" / radio-button + // component. make it one. + &.selected { + @apply bg-buttons-dark; + } + + &:hover { + @apply bg-buttons-light; + } + } + } +} + +.card-content { + @apply bg-cards-secondary; + @apply rounded-b; + + @apply py-2; + @apply px-4; + @apply mb-2; + + display : flex; + flex-direction : column; + flex : 1 0; + justify-content: space-between; +} diff --git a/desktop/angular/src/theme/_colors.scss b/desktop/angular/src/theme/_colors.scss new file mode 100644 index 00000000..65310698 --- /dev/null +++ b/desktop/angular/src/theme/_colors.scss @@ -0,0 +1,46 @@ +/** + * For debugging purposes, we define all our colors as + * CSS3 variables and make tailwind put a reference to those + * variables. This way we will see the variable name in the + * developer-tools instead of the hex/rgba values. + * + * You're welcome 🚀 + */ +:root { + --background: #121213; + + --text-primary : #ffffff; + --text-secondary: #ababab; + --text-tertiary : #888888; + + --cards-primary : #222222; + --cards-secondary : #1b1b1b; + --cards-secondary-rgb: 27, 27, 27; + --cards-tertiary : #2c2c2c; + + --button-icon : #ababab; + --button-dark : #343434; + --button-light: #474747; + + --info-green : #3df57f; + --info-red : #d12e2e; + --info-gray : #ababab; + --info-blue : #4e97fa; + --info-yellow : #e9d31d; + --info-yellow-rgb: 233, 211, 29; + + --protection-ok-primary : rgb(29, 233, 102); + --protection-ok-secondary: rgb(24, 130, 61); + --protection-ok-tertiary : rgb(20, 61, 36); + + --protection-warn-primary : rgb(233, 216, 29); + --protection-warn-secondary: rgb(130, 121, 24); + --protection-warn-tertiary : rgb(61, 58, 20); + + --protection-fail-primary : rgb(224, 29, 29); + --protection-fail-secondary: rgb(129, 24, 24); + --protection-fail-tertiary : rgb(61, 20, 20); + + --portmaster-plus: #2fcfae; + --portmaster-pro: #029ad0; +} diff --git a/desktop/angular/src/theme/_dialog.scss b/desktop/angular/src/theme/_dialog.scss new file mode 100644 index 00000000..c4eaaabe --- /dev/null +++ b/desktop/angular/src/theme/_dialog.scss @@ -0,0 +1,9 @@ +.dialog-screen-backdrop { + backdrop-filter : blur(10px); + background-color: rgba(#000000, 0.7); +} + +.dialog-screen-backdrop-light { + backdrop-filter : blur(3px); + background-color: rgba(#000000, 0.4); +} diff --git a/desktop/angular/src/theme/_drag-n-drop.scss b/desktop/angular/src/theme/_drag-n-drop.scss new file mode 100644 index 00000000..e6c69add --- /dev/null +++ b/desktop/angular/src/theme/_drag-n-drop.scss @@ -0,0 +1,46 @@ +.cdk-drag { + .widget { + user-select: none; + + fa-icon { + opacity: 1; + } + } +} + +.cdk-drag-placeholder { + user-select: none; + position: relative; + opacity: 0.5; + box-sizing: border-box; + cursor: grabbing !important; + @apply border-2; + @apply rounded; + @apply border-dashed; + border-color: #292929; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + user-select: none; +} + +.cdk-drag-preview { + user-select: none; + box-sizing: border-box; + cursor: grabbing !important; + + @apply rounded; + @apply border-2; + @apply border-dashed; + border-color: #292929; + @apply text-primary; +} + +.cdk-drag-handle { + cursor: grab !important; +} + +.document-grabbing { + cursor: grabbing !important; +} diff --git a/desktop/angular/src/theme/_inputs.scss b/desktop/angular/src/theme/_inputs.scss new file mode 100644 index 00000000..a7baf154 --- /dev/null +++ b/desktop/angular/src/theme/_inputs.scss @@ -0,0 +1,35 @@ +input:not([type="checkbox"]), +textarea, +select { + @apply outline-none w-full block; + @apply bg-gray-300 rounded; + @apply text-xs text-primary; + @apply border border-gray-300; + @apply rounded-sm font-medium; + @apply p-1.5; + + transition: border cubic-bezier(0.175, 0.885, 0.32, 1.275) .3s; + + &::placeholder { + @apply text-secondary text-xxs; + } + + &:active, + &:focus { + @apply text-primary; + @apply bg-gray-500 border-gray-400 bg-opacity-75 border-opacity-75; + + &::placeholder { + @apply text-tertiary; + } + } +} + + +input, +textarea, +select { + .ng-invalid { + @apply border-red; + } +} diff --git a/desktop/angular/src/theme/_markdown.scss b/desktop/angular/src/theme/_markdown.scss new file mode 100644 index 00000000..4bf2fa09 --- /dev/null +++ b/desktop/angular/src/theme/_markdown.scss @@ -0,0 +1,455 @@ +// Mostly taken from https://github.com/sindresorhus/github-markdown-css/blob/gh-pages/license + +markdown { + width: 100%; + @apply p-2; + color: white !important; + @apply font-normal; + + details { + display: block; + } + + summary { + display: list-item; + } + + a { + background-color: initial; + } + + a:active, + a:hover { + outline-width: 0; + } + + strong { + font-weight: inherit; + font-weight: bolder; + } + + h1 { + font-size: 2rem; + margin: .67rem 0; + } + + img { + border-style: none; + } + + code, + kbd, + pre { + font-family: monospace, monospace; + font-size: 1rem; + } + + hr { + box-sizing: initial; + height: 0; + overflow: visible; + } + + input { + font: inherit; + margin: 0; + } + + input { + overflow: visible; + } + + [type=checkbox] { + box-sizing: border-box; + padding: 0; + } + + * { + box-sizing: border-box; + } + + input { + font-family: inherit; + font-size: inherit; + line-height: inherit; + } + + a { + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + strong { + font-weight: 600; + } + + hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #dfe2e5; + } + + hr:after, + hr:before { + display: table; + content: ""; + } + + hr:after { + clear: both; + } + + table { + border-spacing: 0; + border-collapse: collapse; + } + + td, + th { + padding: 0; + } + + details summary { + cursor: pointer; + } + + kbd { + display: inline-block; + padding: 3px 5px; + font: 11px SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; + line-height: 10px; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + margin-bottom: 0; + } + + h1 { + font-size: 32px; + } + + h1, + h2 { + font-weight: 600; + } + + h2 { + font-size: 24px; + } + + h3 { + font-size: 20px; + } + + h3, + h4 { + font-weight: 600; + } + + h4 { + font-size: 16px; + } + + h5 { + font-size: 14px; + } + + h5, + h6 { + font-weight: 600; + } + + h6 { + font-size: 12px; + } + + p { + margin-top: 0; + margin-bottom: 10px; + } + + blockquote { + margin: 0; + } + + ol, + ul { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; + } + + ol { + list-style-type: decimal; + } + + ul { + list-style-type: circle; + } + + ol ol, + ul ol { + list-style-type: lower-roman; + } + + ol ol ol, + ol ul ol, + ul ol ol, + ul ul ol { + list-style-type: lower-alpha; + } + + dd { + margin-left: 0; + } + + code, + pre { + font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; + font-size: 12px; + } + + pre { + margin-top: 0; + margin-bottom: 0; + } + + input::-webkit-inner-spin-button, + input::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; + } + + a:not([href]) { + color: inherit; + text-decoration: none; + } + + blockquote, + details, + dl, + ol, + p, + pre, + table, + ul { + margin-top: 0; + // be carefully when ever changing this! + margin-bottom: 16px; + } + + hr { + height: .25rem; + padding: 0; + margin: 24px 0; + background-color: #e1e4e8; + border: 0; + } + + blockquote { + padding: 0 1rem; + border-left: .25rem solid #dfe2e5; + } + + blockquote>:first-child { + margin-top: 0; + } + + blockquote>:last-child { + margin-bottom: 0; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + } + + h1 { + font-size: 2rem; + } + + h1, + h2 { + padding-bottom: .3rem; + border-bottom: 1px solid #eaecef; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.25rem; + } + + h4 { + font-size: 1rem; + } + + h5 { + font-size: .875rem; + } + + h6 { + font-size: .85rem; + } + + ol, + ul { + padding-left: 2rem; + } + + ol ol, + ol ul, + ul ol, + ul ul { + margin-top: 0; + margin-bottom: 0; + } + + li { + word-wrap: break-all; + } + + li>p { + margin-top: 16px; + } + + li+li { + margin-top: .25rem; + } + + dl { + padding: 0; + } + + dl dt { + padding: 0; + margin-top: 16px; + font-size: 1rem; + font-style: italic; + font-weight: 600; + } + + dl dd { + padding: 0 16px; + margin-bottom: 16px; + } + + table { + display: block; + width: 100%; + overflow: auto; + } + + table th { + font-weight: 600; + } + + table td, + table th { + padding: 6px 13px; + border: 1px solid #dfe2e5; + } + + table tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; + } + + table tr:nth-child(2n) { + background-color: #f6f8fa; + } + + img { + max-width: 100%; + box-sizing: initial; + background-color: #fff; + } + + img[align=right] { + padding-left: 20px; + } + + img[align=left] { + padding-right: 20px; + } + + code { + padding: .2rem .4rem; + margin: 0; + font-size: 95%; + background-color: rgba(27, 31, 35, .05); + border-radius: 3px; + } + + pre { + word-wrap: normal; + } + + pre>code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; + } + + .highlight { + margin-bottom: 16px; + } + + .highlight pre { + margin-bottom: 0; + word-break: normal; + } + + .highlight pre, + pre { + padding: 16px; + overflow: auto; + font-size: 90%; + line-height: 1.45; + background-color: #f6f8fa; + border-radius: 3px; + } + + pre code { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: initial; + border: 0; + } +} diff --git a/desktop/angular/src/theme/_pill.scss b/desktop/angular/src/theme/_pill.scss new file mode 100644 index 00000000..e85d9ac3 --- /dev/null +++ b/desktop/angular/src/theme/_pill.scss @@ -0,0 +1,7 @@ +@import 'mixins/_pill.scss'; + +.pill-container { + @include pill-container; + @apply pl-2; + @apply bg-buttons-dark; +} diff --git a/desktop/angular/src/theme/_scroll.scss b/desktop/angular/src/theme/_scroll.scss new file mode 100644 index 00000000..36ea80b0 --- /dev/null +++ b/desktop/angular/src/theme/_scroll.scss @@ -0,0 +1,28 @@ +html, +body { + scroll-behavior: smooth; +} + +::-webkit-scrollbar { + @apply bg-buttons-dark; + width: 4px; +} + +::-webkit-scrollbar-thumb { + @apply bg-buttons-light; + @apply rounded; + cursor: pointer; +} + +.no-scroll { + overflow: hidden; +} + +.scrollable { + width : 100%; + max-height: 100%; + overflow : auto; + overflow-x: hidden; + flex-grow : 1; + @apply px-3; +} diff --git a/desktop/angular/src/theme/_search.scss b/desktop/angular/src/theme/_search.scss new file mode 100644 index 00000000..d950cafd --- /dev/null +++ b/desktop/angular/src/theme/_search.scss @@ -0,0 +1,10 @@ +em.search-result { + @apply text-background; + @apply bg-yellow; + @apply border; + @apply border-yellow; + @apply rounded-sm; + + text-decoration: none; + font-style: inherit; +} diff --git a/desktop/angular/src/theme/_table.scss b/desktop/angular/src/theme/_table.scss new file mode 100644 index 00000000..035b5e17 --- /dev/null +++ b/desktop/angular/src/theme/_table.scss @@ -0,0 +1,41 @@ +table:not(.custom) { + width: 100%; + + th, + tr, + td { + @apply text-xs; + } + + th { + text-align: left; + @apply text-secondary; + z-index: 1; + } + + td, + th { + @apply p-2; + @apply font-medium; + } + + tr:nth-child(even) { + @apply bg-cards-secondary; + --bg-opacity: 0.5; + } + + tr:nth-child(odd) { + @apply bg-cards-tertiary; + --bg-opacity: 0.6; + } + + tr.cdk-header-row th { + @apply bg-cards-tertiary; + --bg-opacity: 1; + + // we cannot use borders directly due to + // the sticky header. Use a box-shadow to + // simulate a border. + box-shadow: 0 2px rgba(0, 0, 0, 0.3); + } +} diff --git a/desktop/angular/src/theme/_tailwind.scss b/desktop/angular/src/theme/_tailwind.scss new file mode 100644 index 00000000..28ccd29f --- /dev/null +++ b/desktop/angular/src/theme/_tailwind.scss @@ -0,0 +1,4 @@ +/** The tailwind post-processor will inject all tailwind styles here **/ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; diff --git a/desktop/angular/src/theme/_trust-level.scss b/desktop/angular/src/theme/_trust-level.scss new file mode 100644 index 00000000..a4b00b51 --- /dev/null +++ b/desktop/angular/src/theme/_trust-level.scss @@ -0,0 +1,73 @@ +span.trust-level { + display : inline-block; + position : relative; + width : 6px; + user-select: none; + overflow : visible; + + &~* { + @apply ml-2; + } + + &:before { + content : ""; + display : block; + position : relative; + height : 6px; + width : 6px; + top : -1px; + left : 0px; + border-radius: 50%; + } + + &.centered:before { + top: 0px; + } + + &:before { + background-color: var(--bg-color); + @apply shadow-inner-xs; + } + + &.pulse:before { + animation : pulsate-trust 1s ease-out infinite; + box-shadow: 0 0 10px var(--glow-color); + } + + &.off { + --bg-color : theme('colors.info.gray'); + --glow-color: theme('colors.info.gray'); + } + + &.auto { + --bg-color : theme('colors.info.blue'); + --glow-color: theme('colors.info.blue'); + } + + &.low { + --bg-color : theme('colors.info.green'); + --glow-color: theme('colors.info.green'); + } + + &.medium { + --bg-color : theme('colors.info.yellow'); + --glow-color: theme('colors.info.yellow'); + } + + &.high { + --bg-color : theme('colors.info.red'); + --glow-color: theme('colors.info.red'); + } +} + +@keyframes pulsate-trust { + 100% { + opacity: 0.8; + } + + 0% { + background: var(--glow-color); + box-shadow: 0 0 0 var(--glow-color); + opacity : 1; + } +} diff --git a/desktop/angular/src/theme/_typography.scss b/desktop/angular/src/theme/_typography.scss new file mode 100644 index 00000000..1c8a6d01 --- /dev/null +++ b/desktop/angular/src/theme/_typography.scss @@ -0,0 +1,61 @@ +html, +body { + font-family: 'Roboto', sans-serif; + @apply text-primary; + @apply font-medium; +} + +body, +.primary-text, +.secondary-text { + @apply text-xs; + @apply font-medium; +} + +label, +.secondary-text { + @apply text-secondary; +} + +.primary-text { + @apply text-primary; +} + +.tertiary-text { + @apply text-tertiary; +} + +h1, +h2, +h3 { + @apply text-primary; +} + +h1 { + display: block; + + @apply mb-1; + @apply text-xl; + @apply font-normal; + @apply mb-2; +} + +h2 { + @apply p-2; + @apply ml-2; + @apply text-lg; + @apply font-medium; + letter-spacing: -0.01rem; +} + +h3 { + @apply mb-1; + @apply text-base; + @apply font-medium; +} + +h4 { + @apply text-xs; + @apply font-medium; + @apply text-tertiary; +} diff --git a/desktop/angular/src/theme/_verdict.scss b/desktop/angular/src/theme/_verdict.scss new file mode 100644 index 00000000..9f4a2730 --- /dev/null +++ b/desktop/angular/src/theme/_verdict.scss @@ -0,0 +1,47 @@ +span.verdict { + display : inline-block; + position : relative; + width : 12px; + height : 9px; + align-self : center; + justify-self: center; + user-select : none; + overflow : visible; + + &:before { + content : ""; + display : block; + position : absolute; + height : 8px; + width : 8px; + top : 0px; + left : 0px; + border-radius : 50%; + background-color: var(--bg-color); + border : 1px solid var(--bg-color); + @apply shadow-inner-xs; + } + + &.failed { + --bg-color: theme('colors.info.yellow'); + } + + &.accept, + &.reroutetons, + &.reroutetotunnel { + --bg-color: theme('colors.info.green'); + } + + &.block, + &.drop { + --bg-color: theme('colors.info.red'); + } + + &.outdated { + &:before { + background-color: transparent; + border-color : var(--bg-color); + opacity : .85; + } + } +} diff --git a/desktop/angular/src/theme/mixins/_pill.scss b/desktop/angular/src/theme/mixins/_pill.scss new file mode 100644 index 00000000..130f5519 --- /dev/null +++ b/desktop/angular/src/theme/mixins/_pill.scss @@ -0,0 +1,42 @@ +@mixin pill-container { + display : flex; + width : auto; + height : 18px; + align-items : center; + justify-content: flex-end; + font-size : 0.6rem; + line-height : 18px; + + border-radius: 0.5rem; + transform : scale(0.95); + + .counter { + flex-grow : 1; + display : inline-block; + text-align : right; + padding-right: 4px; + padding-left : 2px; + color : #999999ee; + font-size : 0.65rem; + font-weight : 800; + width : max-content; + } + + .pill { + display : inline-block; + width : 29px; + height : 5px; + background-color: #686868; + border-radius : 1rem; + overflow : hidden; + margin-left : 0.2rem; + margin-right : 0.6rem; + + .percentage { + display : block; + height : 100%; + width : 75%; + background-color: #21ad58; + } + } +} diff --git a/desktop/angular/tailwind.config.js b/desktop/angular/tailwind.config.js new file mode 100644 index 00000000..ba4e7f11 --- /dev/null +++ b/desktop/angular/tailwind.config.js @@ -0,0 +1,127 @@ +const plugin = require("tailwindcss/plugin"); + +module.exports = { + content: [ + "./src/**/*.{html,scss,css,ts}", + "./projects/**/*.{html,scss,css,ts}", + ], + theme: { + colors: { + transparent: "transparent", + current: "currentColor", + white: "#ffffff", + background: "#121213", + + gray: { + 100: "#131111", + 200: "#1b1b1b", + 300: "#222222", + 400: "#2c2c2c", + 500: "#474747", + 600: "#888888", + 700: "#ababab", + DEFAULT: "#ababab", + }, + + green: { + 100: "#143d24", + 200: "#18823d", + 300: "#1de966", + DEFAULT: "#18823d", + }, + + red: { + 100: "#3d1414", + 200: "#811818", + 300: "#e01d1d", + DEFAULT: "#d12e2e", + }, + + yellow: { + 100: "#3d3a14", + 200: "#827918", + 300: "#e9d81d", + DEFAULT: "#e9d81d", + }, + + cyan: { + 100: "#b2ebf2", + 200: "#80deea", + 300: "#4dd0e1", + 400: "#26c6da", + 500: "#00bcd4", + 600: "#00acc1", + 700: "#0097a7", + 800: "#00838f", + 900: "#006064", + }, + + deepPurple: { + 50: "#ede7f6", + 100: "#d1c4e9", + 200: "#b39ddb", + 300: "#9575cd", + 400: "#7e57c2", + 500: "#673ab7", + 600: "#5e35b1", + 700: "#512da8", + 800: "#4527a0", + 900: "#311b92", + }, + + blue: { + DEFAULT: "#4e97fa", + }, + + // Legacy color definitions + + // The overall application background color + + // Text shades + cards: { + primary: "var(--cards-primary)", + secondary: "var(--cards-secondary)", + tertiary: "var(--cards-tertiary)", + }, + + buttons: { + icon: "var(--button-icon)", + dark: "var(--button-dark)", + light: "var(--button-light)", + }, + + info: { + green: "var(--info-green)", + red: "var(--info-red)", + gray: "var(--info-gray)", + blue: "var(--info-blue)", + yellow: "var(--info-yellow)", + }, + }, + textColor: (theme) => { + return { + primary: theme("colors.white"), + secondary: theme("colors.gray.700"), + tertiary: theme("colors.gray.600"), + + ...theme("colors"), + }; + }, + extend: { + boxShadow: { + xs: "0 0 0 1px rgba(0, 0, 0, 0.05)", + "inner-xs": "inset 0 2px 4px 0 rgba(0, 0, 0, 0.16)", + }, + fontSize: { + xxs: "0.7rem", + }, + }, + }, + plugins: [ + plugin(function ({ addVariant, theme }) { + Object.keys(theme("screens")).forEach((key) => { + addVariant("sfng-" + key, ".min-width-" + theme("screens")[key] + " &"); + }); + }), + ], +}; diff --git a/desktop/angular/tsconfig.app.json b/desktop/angular/tsconfig.app.json new file mode 100644 index 00000000..f67c4660 --- /dev/null +++ b/desktop/angular/tsconfig.app.json @@ -0,0 +1,16 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [ + ] + }, + "files": [ + "src/main.ts", + "src/polyfills.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/desktop/angular/tsconfig.json b/desktop/angular/tsconfig.json new file mode 100644 index 00000000..281e9628 --- /dev/null +++ b/desktop/angular/tsconfig.json @@ -0,0 +1,41 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "paths": { + "@safing/portmaster-api": [ + "dist-lib/safing/portmaster-api" + ], + "@safing/ui": [ + "dist-lib/safing/ui" + ] + }, + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "es2020", + "lib": [ + "es2018", + "dom" + ], + "types": [ + "./src/electron-app.d.ts", + "chrome" + ], + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictTemplates": true + } +} diff --git a/desktop/angular/tsconfig.spec.json b/desktop/angular/tsconfig.spec.json new file mode 100644 index 00000000..800c6e2f --- /dev/null +++ b/desktop/angular/tsconfig.spec.json @@ -0,0 +1,19 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "files": [ + "src/test.ts", + "src/polyfills.ts" + ], + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts", + "src/app/widgets/status-widget-factory/settings.ts" + ] +} diff --git a/desktop/tauri/.cargo/config.toml b/desktop/tauri/.cargo/config.toml new file mode 100644 index 00000000..707bafa6 --- /dev/null +++ b/desktop/tauri/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" +rustflags = ["-C", "link-args=-L/usr/lib/aarch64-linux-gnu/"] + +[target.armv7-unknown-linux-gnueabihf] +linker = "arm-linux-gnueabihf-gcc" +rustflags = ["-C", "link-args=-L/usr/lib/arm-linux-gnueabihf/"] diff --git a/desktop/tauri/assets b/desktop/tauri/assets new file mode 120000 index 00000000..21dab851 --- /dev/null +++ b/desktop/tauri/assets @@ -0,0 +1 @@ +../../assets/data \ No newline at end of file diff --git a/desktop/tauri/src-tauri/.gitignore b/desktop/tauri/src-tauri/.gitignore new file mode 100644 index 00000000..aba21e24 --- /dev/null +++ b/desktop/tauri/src-tauri/.gitignore @@ -0,0 +1,3 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ diff --git a/desktop/tauri/src-tauri/Cargo.lock b/desktop/tauri/src-tauri/Cargo.lock new file mode 100644 index 00000000..a0bda3a8 --- /dev/null +++ b/desktop/tauri/src-tauri/Cargo.lock @@ -0,0 +1,7286 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom 0.2.11", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anstream" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "assert_matches", + "cached", + "ctor", + "dataurl", + "dirs", + "futures-util", + "gdk-pixbuf", + "gdk-pixbuf-sys", + "gio-sys 0.18.1", + "glib 0.18.4", + "glib-sys 0.18.1", + "gtk", + "gtk-sys", + "http 1.0.0", + "lazy_static", + "log", + "notify-rust", + "pretty_env_logger", + "rust-ini", + "serde", + "serde_json", + "sha", + "tauri", + "tauri-build", + "tauri-cli", + "tauri-plugin-cli", + "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", + "tauri-plugin-notification", + "tauri-plugin-os", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "thiserror", + "tokio", + "tokio-websockets", + "url", + "uuid", + "which", + "windows 0.54.0", + "windows-service", +] + +[[package]] +name = "ar" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67af77d68a931ecd5cbd8a3b5987d63a1d1d1278f7f6a60ae33db485cdebb69" + +[[package]] +name = "arboard" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafb29b107435aa276664c1db8954ac27a6e105cdad3c88287a199eb0e313c08" +dependencies = [ + "clipboard-win", + "core-graphics 0.22.3", + "image", + "log", + "objc", + "objc-foundation", + "objc_id", + "parking_lot", + "thiserror", + "winapi", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca33f4bc4ed1babef42cad36cc1f51fa88be00420404e5b1e80ab1b18f7678c" +dependencies = [ + "concurrent-queue", + "event-listener 4.0.0", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ae5ebefcc48e7452b4987947920dac9450be1110cadf34d1b8c116bdbaf97c" +dependencies = [ + "async-lock 3.2.0", + "async-task", + "concurrent-queue", + "fastrand 2.0.1", + "futures-lite 2.1.0", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.27", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6afaa937395a620e33dc6a742c593c01aced20aa376ffb0f628121198578ccc7" +dependencies = [ + "async-lock 3.2.0", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.1.0", + "parking", + "polling 3.3.1", + "rustix 0.38.30", + "slab", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7125e42787d53db9dd54261812ef17e937c95a51e4d291373b670342fa44310c" +dependencies = [ + "event-listener 4.0.0", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.30", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-recursion" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "async-signal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e47d90f65a225c4527103a8d747001fc56e375203592b25ad103e1ca13124c5" +dependencies = [ + "async-io 2.2.2", + "async-lock 2.8.0", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.30", + "signal-hook-registry", + "slab", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-task" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eb2cdb97421e01129ccb49169d8279ed21e829929144f4a22a6e54ac549ca1" + +[[package]] +name = "async-trait" +version = "0.1.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "atk" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4af014b17dd80e8af9fa689b2d4a211ddba6eb583c1622f35d0cb543f6b17e4" +dependencies = [ + "atk-sys", + "glib 0.18.4", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251e0b7d90e33e0ba930891a505a9a35ece37b2dd37a14f3ffc306c13b980009" +dependencies = [ + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.21.5", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.11", + "http-body", + "hyper", + "itoa 1.0.10", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.11", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +dependencies = [ + "serde", +] + +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a37913e8dc4ddcc604f0c6d3bf2887c995153af3611de9e23c352b44c1b9118" +dependencies = [ + "async-channel", + "async-lock 3.2.0", + "async-task", + "fastrand 2.0.1", + "futures-io", + "futures-lite 2.1.0", + "piper", + "tracing", +] + +[[package]] +name = "brotli" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bswap" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3acc5ce9c60e68df21b877f13f908ef95c89f01cb6c656cf76ba95f10bc72f5" + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "bytecount" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" + +[[package]] +name = "bytemuck" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cached" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c8c50262271cdf5abc979a5f76515c234e764fa025d1ba4862c0f0bcda0e95" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.3", + "instant", + "once_cell", + "thiserror", +] + +[[package]] +name = "cached_proc_macro" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c878c71c2821aa2058722038a59a67583a4240524687c6028571c9b395ded61f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663" + +[[package]] +name = "cairo-rs" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33613627f0dea6a731b0605101fad59ba4f193a52c96c4687728d822605a8a1" +dependencies = [ + "bitflags 2.4.1", + "cairo-sys-rs", + "glib 0.18.4", + "libc", + "once_cell", + "thiserror", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys 0.18.1", + "libc", + "system-deps", +] + +[[package]] +name = "cargo_toml" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1ece59890e746567b467253aea0adbe8a21784d0b025d8a306f66c391c2957" +dependencies = [ + "serde", + "toml 0.8.2", +] + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "jobserver", + "libc", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets 0.48.5", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "clap" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.10.0", +] + +[[package]] +name = "clap_complete" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885e4d7d5af40bfb99ae6f9433e292feac98d452dcb3ec3d25dfe7552b77da8c" +dependencies = [ + "clap 4.4.11", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "clipboard-win" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.23.1", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common-path" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2382f75942f4b3be3690fe4f86365e9c853c1587d6ee58212cebf6e2a9ccd101" + +[[package]] +name = "concurrent-queue" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16048cd947b08fa32c24458a22f5dc5e835264f689f4f5653210c69fd107363" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + +[[package]] +name = "const-random" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaf16c9c2c612020bcfd042e170f6e32de9b9d75adb5277cdbbd2e2c8c8299a" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.11", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset 0.9.0", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.52", +] + +[[package]] +name = "ctor" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d2b3721e861707777e3195b0158f950ae6dc4a27e4d02ff9f67e3eb3de199e" +dependencies = [ + "quote", + "syn 2.0.52", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctrlc" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b467862cc8610ca6fc9a1532d7777cee0804e678ab45410897b9396495994a0b" +dependencies = [ + "nix 0.27.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core 0.20.3", + "darling_macro 0.20.3", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.52", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core 0.20.3", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "dataurl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a1f14ed857323d318ca723a05a456196347efbe855f712f68cf6b8a14f8f15" +dependencies = [ + "atty", + "base64 0.13.1", + "clap 2.34.0", + "encoding_rs", + "percent-encoding", + "url", +] + +[[package]] +name = "deranged" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users 0.3.5", + "winapi", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.4", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + +[[package]] +name = "dtoa-short" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbaceec3c6e4211c79e7b1800fb9680527106beb2f9c51904a3210c03a448c74" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] +name = "embed-resource" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54cc3e827ee1c3812239a9a41dede7b4d7d5d5464faa32d71bd7cba28ce2cb2" +dependencies = [ + "cc", + "rustc_version", + "toml 0.8.2", + "vswhom", + "winreg 0.51.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5998b4f30320c9d93aed72f63af821bfdac50465b75428fce77b48ec482c3939" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f95e2801cd355d4a1a3e3953ce6ee5ae9603a5c833455343a8bfe3f44d418246" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "erased-serde" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b73807008a3c7f171cc40312f37d95ef0396e048b5848d775f54b1a4dd4a0d3" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "770d968249b5d99410d61f5bf89057f3199a077a04d087092f58e7d10692baae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" +dependencies = [ + "event-listener 4.0.0", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.72.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887d93f60543e9a9362ef8a21beedd0a833c5d9610e18c67abe15a5963dcb1a4" +dependencies = [ + "bit_field", + "flume", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "fdeflate" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d6dafc854908ff5da46ff3f8f473c6984119a2876a383a860246dd7841a868" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.0", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" + +[[package]] +name = "futures-executor" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeee267a1883f7ebef3700f262d2d54de95dfaf38189015a74fdc4e0c7ad8143" +dependencies = [ + "fastrand 2.0.1", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "futures-sink" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" + +[[package]] +name = "futures-task" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" + +[[package]] +name = "futures-util" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ba081bdef3b75ebcdbfc953699ed2d7417d6bd853347a42a37d76406a33646" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib 0.18.4", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446f32b74d22c33b7b258d4af4ffde53c2bf96ca2e29abdf1a785fe59bd6c82c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib 0.18.4", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ff856cb3386dae1703a920f803abafcc580e9b5f711ca62ed1620c25b51ff2" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90fbf5c033c65d93792192a49a8efb5bb1e640c419682a58bb96f5ae77f3d4a" +dependencies = [ + "gdk-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2ea8a4909d530f79921290389cbd7c34cb9d623bfe970eaae65ca5f9cd9cce" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib 0.18.4", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee8f00f4ee46cad2939b8990f5c70c94ff882c3028f3cc5abf950fa4ab53043" +dependencies = [ + "gdk-sys", + "glib-sys 0.18.1", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows 0.48.0", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb65d4ba3173c56a500b555b532f72c42e8d1fe64962b518897f8959fae2c177" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys 0.18.1", + "glib 0.18.4", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror", +] + +[[package]] +name = "gio-sys" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b693b8e39d042a95547fc258a7b07349b1f0b48f4b2fa3108ba3c51c0b5229" +dependencies = [ + "glib-sys 0.16.3", + "gobject-sys 0.16.3", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16aa2475c9debed5a32832cb5ff2af5a3f9e1ab9e69df58eaadc1ab2004d6eba" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.16.3", + "glib-macros 0.16.8", + "glib-sys 0.16.3", + "gobject-sys 0.16.3", + "libc", + "once_cell", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951bbd7fdc5c044ede9f05170f05a3ae9479239c3afdfe2d22d537a3add15c4e" +dependencies = [ + "bitflags 2.4.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.18.1", + "glib-macros 0.18.3", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib-macros" +version = "0.16.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1a9325847aa46f1e96ffea37611b9d51fc4827e67f79e7de502a297560a67b" +dependencies = [ + "anyhow", + "heck", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "glib-macros" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72793962ceece3863c2965d7f10c8786323b17c7adea75a515809fa20ab799a5" +dependencies = [ + "heck", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "glib-sys" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61a4f46316d06bfa33a7ac22df6f0524c8be58e3db2d9ca99ccb1f357b62a65" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.3", + "regex-syntax 0.8.2", +] + +[[package]] +name = "gobject-sys" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3520bb9c07ae2a12c7f2fbb24d4efc11231c8146a86956413fb1a79bb760a0f1" +dependencies = [ + "glib-sys 0.16.3", + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys 0.18.1", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c4f5e0e20b60e10631a5f06da7fe3dda744b05ad0ea71fee2f47adf865890c" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib 0.18.4", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771437bf1de2c1c0b496c11505bdf748e26066bbe942dfc8f614c9460f6d7722" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6063efb63db582968fb7df72e1ae68aa6360dcfb0a75143f34fc7d616bad75e" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "h2" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.11", + "indexmap 2.1.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eceaaeec696539ddaf7b333340f1af35a5aa87ae3e4f3ead0532f72affab2e" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "handlebars" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa67bab9ff362228eb3d00bd024a4965d8231bbb7921167f0cfa66c6626b225" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.10", +] + +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.10", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.11", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.11", + "http-body", + "httparse", + "httpdate", + "itoa 1.0.10", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core 0.51.1", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3804960be0bb5e4edb1e1ad67afd321a9ecfd875c3e65c099468fd2717d7cae" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.3", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f3dfdbdd72063086ff443e297b61695500514b1e41095b6fb9a5ab48a70a711" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-rational", + "num-traits", + "png", + "qoi", + "tiff", +] + +[[package]] +name = "include_dir" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18762faeff7122e89e0857b02f7ce6fcc0d101d5e9ad2ad7846cc01d61b7f19e" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", + "serde", +] + +[[package]] +name = "infer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc" +dependencies = [ + "cfb", +] + +[[package]] +name = "infer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.3", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi 0.3.3", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "iso8601" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153" +dependencies = [ + "nom", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib 0.18.4", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cee9c64da59eae3b50095c18d3e74f8b73c0b86d2792824ff01bbce68ba229ca" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff1e1486799e3f64129f8ccad108b38290df9cd7015cd31bed17239f0789d6" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "treediff", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonschema" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a071f4f7efc9a9118dfb627a0a94ef247986e1ab8606a4c806ae2b3aa3b6978" +dependencies = [ + "ahash", + "anyhow", + "base64 0.21.5", + "bytecount", + "clap 4.4.11", + "fancy-regex", + "fraction", + "getrandom 0.2.11", + "iso8601", + "itoa 1.0.10", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.4.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kuchikiki" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 1.9.3", + "matches", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib 0.18.4", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall 0.4.1", +] + +[[package]] +name = "line-wrap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" +dependencies = [ + "safemem", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +dependencies = [ + "value-bag", +] + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac-notification-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fca4d74ff9dbaac16a01b924bc3693fa2bba0862c2c633abc73f9a8ea21f64" +dependencies = [ + "cc", + "dirs-next", + "objc-foundation", + "objc_id", + "time", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minisign" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23ef13ff1d745b1e52397daaa247e333c607f3cff96d4df2b798dc252db974b" +dependencies = [ + "getrandom 0.2.11", + "rpassword", + "scrypt", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "muda" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b564d551449738387fb4541aef5fbfceaa81b2b732f2534c1c7c89dc7d673eaa" +dependencies = [ + "cocoa", + "crossbeam-channel", + "gtk", + "keyboard-types", + "objc", + "once_cell", + "png", + "serde", + "thiserror", + "windows-sys 0.52.0", +] + +[[package]] +name = "ndk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451422b7e4718271c8b5b3aadf5adedba43dc76312454b387e98fae0fc951aa0" +dependencies = [ + "bitflags 1.3.2", + "jni-sys", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.4.1+23.1.7779620" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf2aae958bd232cac5069850591667ad422d263686d75b52a065f9badeee5a3" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "libc", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.4.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + +[[package]] +name = "notify-rust" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "827c5edfa80235ded4ab3fe8e9dc619b4f866ef16fe9b1c6b8a7f8692c0f2226" +dependencies = [ + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c6602fda94a57c990fe0df199a035d83576b496aa29f4e634a8ac6004e68a6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.3", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a083c0c7e5e4a8ec4176346cf61f67ac674e8bfb059d9226e1c54a96b377c12" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d6a8c22fc714f0c2373e6091bf6f5e9b37b1bc0b1184874b7e0a4e303d318f" +dependencies = [ + "dlv-list", + "hashbrown 0.14.3", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "006e42d5b888366f1880eda20371fedde764ed2213dc8496f49622fa0c99cd5e" +dependencies = [ + "log", + "serde", + "winapi", +] + +[[package]] +name = "os_pipe" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae859aa07428ca9a929b936690f8b12dc5f11dd8c6992a18ca93919f28bc177" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib 0.18.4", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f8023d0fb78c8e03784ea1c7f3fa36e68a723138990b8d5a47d916b651e7a8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d24f72393fd16ab6ac5738bc33cdb6a9aa73f8b902e8fe29cf4e67d7dd1026" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "pest_meta" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934cd7631c050f4674352a6e835d5f6711ffbfb9345c2fc0107155ac495ae293" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668d31b1c4eba19242f2088b2bf3316b82ca31082a8335764db4e083db7485d4" +dependencies = [ + "atomic-waker", + "fastrand 2.0.1", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "plist" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5699cc8a63d1aa2b1ee8e12b9ad70ac790d65788cd36101fa37f87ea46c4cef" +dependencies = [ + "base64 0.21.5", + "indexmap 2.1.0", + "line-wrap", + "quick-xml 0.31.0", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd75bf2d8dd3702b9707cdbc56a5b9ef42cec752eb8b3bafc01234558442aa64" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf63fa624ab313c11656b4cda960bfc46c410187ad493c41f6ba2d8c1e991c9e" +dependencies = [ + "cfg-if", + "concurrent-queue", + "pin-project-lite", + "rustix 0.38.30", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.11", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "rayon" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall 0.1.57", + "rust-argon2", +] + +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom 0.2.11", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.3", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "reqwest" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +dependencies = [ + "base64 0.21.5", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.11", + "http-body", + "hyper", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg 0.50.0", +] + +[[package]] +name = "rfd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241a0deb168c88050d872294f7b3106c1dfa8740942bcc97bc91b98e97b5c501" +dependencies = [ + "block", + "dispatch", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "gtk-sys", + "js-sys", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom 0.2.11", + "libc", + "spin", + "untrusted", + "windows-sys 0.48.0", +] + +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64 0.13.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.37.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys 0.4.12", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6b63262c9fcac8659abfaa96cac103d28166d3ff3eaf8f412e19f3ae9e5a48" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7673e0aa20ee4937c6aacfc12bb8341cfbf054cdd21df6bec5fd0629fe9339b" + +[[package]] +name = "rustls-webpki" +version = "0.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2635c8bc2b88d367767c5de8ea1d8db9af3f6219eba28442242d9ab81d1b89" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a28f4c49489add4ce10783f7911893516f15afe45d015608d41faca6bc4d29" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "schemars_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c767fd6fa65d9ccf9cf026122c1b555f2ef9a4f0cea69da4d7dbc3e258d30967" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 1.0.109", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", + "thin-slice", +] + +[[package]] +name = "semver" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "serde_fmt" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "itoa 1.0.10", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa 1.0.10", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "serde_spanned" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.10", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +dependencies = [ + "base64 0.21.5", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.1.0", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +dependencies = [ + "darling 0.20.3", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4208d5a903276a9f3b797afdf6c5bc12a8da1344b053b100abf3565ecc80cb7e" +dependencies = [ + "bswap", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_child" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib 0.18.4", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "sval" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a2386bea23a121e4e72450306b1dd01078b6399af11b93897bf84640a28a59" + +[[package]] +name = "sval_buffer" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16c047898a0e19002005512243bc9ef1c1037aad7d03d6c594e234efec80795" +dependencies = [ + "sval", + "sval_ref", +] + +[[package]] +name = "sval_dynamic" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74fb116e2ecdcb280b0108aa2ee4434df50606c3208c47ac95432730eaac20c" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10837b4f0feccef271b2b1c03784e08f6d0bb6d23272ec9e8c777bfadbb8f1b8" +dependencies = [ + "itoa 1.0.10", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891f5ecdf34ce61a8ab2d10f9cfdc303347b0afec4dad6702757419d2d8312a9" +dependencies = [ + "itoa 1.0.10", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63fcffb4b79c531f38e3090788b64f3f4d54a180aacf02d69c42fa4e4bf284c3" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af725f9c2aa7cec4ca9c47da2cc90920c4c82d3fa537094c66c77a5459f5809d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7589c649a03d21df40b9a926787d2c64937fa1dccec8d87c6cd82989a2e0a4" +dependencies = [ + "serde", + "sval", + "sval_nested", +] + +[[package]] +name = "swift-rs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bbdb58577b6301f8d17ae2561f32002a5bae056d444e0f69e611e504a276204" +dependencies = [ + "base64 0.21.5", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sys-locale" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e801cf239ecd6ccd71f03d270d67dd53d13e90aab208bf4b8fe4ad957ea949b0" +dependencies = [ + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0dff18fed076d29cb5779e918ef4b8a5dbb756204e4a027794f0bce233d949" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cocoa", + "core-foundation", + "core-graphics 0.23.1", + "crossbeam-channel", + "dispatch", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "image", + "instant", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc", + "once_cell", + "parking_lot", + "png", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.52.0", + "windows-implement", + "windows-version", + "x11-dl", + "zbus", +] + +[[package]] +name = "tao-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec114582505d158b669b136e6851f85840c109819d77c42bb7c0709f727d18c2" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tar" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c39fd04924ca3a864207c66fc2cd7d22d7c016007f9ce846cbb9326331930a" + +[[package]] +name = "tauri" +version = "2.0.0-alpha.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05fb63873c39d3fd5ddad995d395e7b7394ece0b69aeacb31e91d24af48f3de1" +dependencies = [ + "anyhow", + "bytes", + "cocoa", + "dirs-next", + "embed_plist", + "futures-util", + "getrandom 0.2.11", + "glob", + "gtk", + "heck", + "http 0.2.11", + "ico", + "infer 0.15.0", + "jni", + "libc", + "log", + "mime", + "muda", + "objc", + "percent-encoding", + "png", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "state", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils 2.0.0-alpha.12", + "thiserror", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.52.0", +] + +[[package]] +name = "tauri-build" +version = "2.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a2582ffb43e5c28932c43ffc40c295a9196a9a33ffb1163269c6baed84834a" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs-next", + "heck", + "json-patch", + "plist", + "semver", + "serde", + "serde_json", + "swift-rs", + "tauri-utils 2.0.0-alpha.12", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-bundler" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "657d0d0b2e820978ee51109bd75f03cee23dc1a83388d08f82fd368635c14e04" +dependencies = [ + "anyhow", + "ar", + "dirs-next", + "dunce", + "flate2", + "glob", + "handlebars", + "heck", + "hex", + "image", + "log", + "md5", + "os_pipe", + "plist", + "regex", + "semver", + "serde", + "serde_json", + "sha1", + "sha2", + "strsim 0.10.0", + "tar", + "tauri-icns", + "tauri-utils 1.5.3", + "tempfile", + "thiserror", + "time", + "ureq", + "uuid", + "walkdir", + "windows-sys 0.48.0", + "winreg 0.51.0", + "zip", +] + +[[package]] +name = "tauri-cli" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b2a88fd572b14c0ca224675aaad590e38d95e53846df55459edbdc910795eb" +dependencies = [ + "anyhow", + "axum", + "base64 0.21.5", + "cc", + "clap 4.4.11", + "clap_complete", + "colored", + "common-path", + "ctrlc", + "dialoguer", + "env_logger", + "glob", + "handlebars", + "heck", + "html5ever", + "ignore", + "image", + "include_dir", + "itertools", + "json-patch", + "jsonschema", + "kuchikiki", + "libc", + "log", + "minisign", + "notify", + "notify-debouncer-mini", + "once_cell", + "os_info", + "os_pipe", + "regex", + "semver", + "serde", + "serde-value", + "serde_json", + "shared_child", + "tauri-bundler", + "tauri-icns", + "tauri-utils 1.5.3", + "tokio", + "toml 0.8.2", + "toml_edit 0.21.1", + "unicode-width", + "ureq", + "url", + "winapi", + "zeroize", +] + +[[package]] +name = "tauri-codegen" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06976ec7b704d6b842169ffd4ce596e9ce45917a0ab462cb96a119fa2829be9" +dependencies = [ + "base64 0.21.5", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "tauri-utils 2.0.0-alpha.12", + "thiserror", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-icns" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b7eb4d0d43724ba9ba6a6717420ee68aee377816a3edbb45db8c18862b1431" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "tauri-macros" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff509be5a5ac34ec2e60d9029af1032c0a33e421f3e823bc92695192e2871c17" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.52", + "tauri-codegen", + "tauri-utils 2.0.0-alpha.12", +] + +[[package]] +name = "tauri-plugin-cli" +version = "2.0.0-alpha.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3ce9173dd3ae43c5c7529cce495e89cc9d8773adcd2a3e0efb5123aa052c64" +dependencies = [ + "clap 4.4.11", + "log", + "serde", + "serde_json", + "tauri", + "thiserror", +] + +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32d537328bba01bcbbff4fc7daa1175744afdd42e554b6c897d9a1b1f76b023" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "thiserror", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ead9b1276ed45ffec0a27ff51239614fa9b462a7483f5cb98f0c555a40754e9" +dependencies = [ + "glib 0.16.9", + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-fs", + "thiserror", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c81479fdc92bab8609d896249e016404f9fac24a27ddf66e1daafd4db1a35" +dependencies = [ + "anyhow", + "glob", + "serde", + "tauri", + "thiserror", + "uuid", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.0.0-alpha.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e1cfe331495d0e72b9d48191eec98a54f9e189571b8ec6affb39b90b3df3bc" +dependencies = [ + "log", + "notify-rust", + "rand 0.8.5", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-build", + "thiserror", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-os" +version = "2.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dfd82d59cdf0229ffe62d38e12bdfee053c4f915883afe6f982b672a7e28d44" +dependencies = [ + "gethostname 0.4.3", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "thiserror", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdf84ccb3f5ac4df2dfeb5e2f09b9048d8633d9b98d72c701aba72642790f2d9" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "serde", + "serde_json", + "shared_child", + "tauri", + "thiserror", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d25b229dea0a7cb72ab43ebd17fa7479eda058678bead1ecca431013d5e5ebf" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "thiserror", + "windows-sys 0.52.0", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "1.0.0-alpha.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a989e58af6e554dbac798a0a8d112faafc1509bcfab626466181e0724f09c5" +dependencies = [ + "gtk", + "http 0.2.11", + "jni", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils 2.0.0-alpha.12", + "thiserror", + "url", + "windows 0.52.0", +] + +[[package]] +name = "tauri-runtime-wry" +version = "1.0.0-alpha.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9f181a6f5f982204ae293c19f37ba90116b8ec0bfd0a08c7a7ba67200cd9e3" +dependencies = [ + "cocoa", + "gtk", + "http 0.2.11", + "jni", + "percent-encoding", + "raw-window-handle", + "tao", + "tauri-runtime", + "tauri-utils 2.0.0-alpha.12", + "webkit2gtk", + "webview2-com", + "windows 0.52.0", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ad0bbb31fccd1f4c56275d0a5c3abdf1f59999f72cb4ef8b79b4ed42082a21" +dependencies = [ + "aes-gcm", + "ctor", + "dunce", + "getrandom 0.2.11", + "glob", + "heck", + "html5ever", + "infer 0.13.0", + "json-patch", + "json5", + "kuchikiki", + "log", + "memchr", + "phf 0.11.2", + "schemars", + "semver", + "serde", + "serde_json", + "serde_with", + "serialize-to-javascript", + "thiserror", + "toml 0.7.8", + "url", + "walkdir", + "windows-version", +] + +[[package]] +name = "tauri-utils" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4858f99fc9f28b72008ef51d04d18b7e3646845c2bc18ee340045fed6ed5095" +dependencies = [ + "brotli", + "ctor", + "dunce", + "glob", + "heck", + "html5ever", + "infer 0.15.0", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.2", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "serde_with", + "thiserror", + "url", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb" +dependencies = [ + "embed-resource", + "toml 0.7.8", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "006851c9ccefa3c38a7646b8cec804bb429def3da10497bfa977179869c3e8e2" +dependencies = [ + "quick-xml 0.30.0", + "windows 0.51.1", +] + +[[package]] +name = "tempfile" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" +dependencies = [ + "cfg-if", + "fastrand 2.0.1", + "redox_syscall 0.4.1", + "rustix 0.38.30", + "windows-sys 0.48.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tiff" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d172b0f4d3fba17ba89811858b9d3d97f928aece846475bbda076ca46736211" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +dependencies = [ + "deranged", + "itoa 1.0.10", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2 0.5.5", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tokio-websockets" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b069bad86dda43d908b4221fe04fe49d2ed8e0a24d319a5c6a8d250e76fe15b" +dependencies = [ + "base64 0.21.5", + "bytes", + "futures-core", + "futures-sink", + "http 1.0.0", + "httparse", + "rand 0.8.5", + "ring", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", +] + +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.19.15", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.1.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.1.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap 2.1.0", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad962d06d2bfd9b2ab4f665fc73b175523b834b1466a294520201c5845145f8" +dependencies = [ + "cocoa", + "core-graphics 0.23.1", + "crossbeam-channel", + "dirs-next", + "libappindicator", + "muda", + "objc", + "once_cell", + "png", + "serde", + "thiserror", + "windows-sys 0.52.0", +] + +[[package]] +name = "treediff" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52984d277bdf2a751072b5df30ec0377febdb02f7696d64c2d7d54630bac4303" +dependencies = [ + "serde_json", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 0.2.11", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset 0.9.0", + "tempfile", + "winapi", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f214ce18d8b2cbe84ed3aa6486ed3f5b285cf8d8fbdbce9f3f767a724adc35" +dependencies = [ + "base64 0.21.5", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "socks", + "url", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "uuid" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +dependencies = [ + "getrandom 0.2.11", + "sha1_smol", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "value-bag" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74797339c3b98616c009c7c3eb53a0ce41e85c8ec66bd3db96ed132d20cfdee8" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc35703541cbccb5278ef7b589d79439fc808ff0b5867195a3230f9a47421d39" +dependencies = [ + "erased-serde", + "serde", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285b43c29d0b4c0e65aad24561baee67a1b69dc9be9375d4a85138cbf556f7f8" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690" + +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.52", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac36a15a220124ac510204aec1c3e5db8a22ab06fd6706d881dc6149f8ed9a12" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" + +[[package]] +name = "wasm-streams" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4609d447824375f43e1ffbc051b50ad8f4b3ae8219680c94452ea05eb240ac7" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c24a44ec86bb68fbecd1b3efed7e85ea5621b39b35ef2766b66cd984f8010f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys 0.18.1", + "glib 0.18.4", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0ae9c7e420783826cf769d2c06ac9ba462f450eca5893bb8c6c6529a4e5dd33" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.52.0", + "windows-core 0.52.0", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1345798ecd8122468840bcdf1b95e5dc6d2206c5e4b0eafa078d061f59c9bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "webview2-com-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ad85fceee6c42fa3d61239eba5a11401bf38407a849ed5ea1b407df08cca72" +dependencies = [ + "thiserror", + "windows 0.52.0", + "windows-core 0.52.0", +] + +[[package]] +name = "weezl" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" + +[[package]] +name = "which" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fa5e0c10bf77f44aac573e498d1a82d5fbd5e91f6fc0a99e7be4b38e85e101c" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.30", + "windows-sys 0.52.0", +] + +[[package]] +name = "widestring" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-wsapoll" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af6abc2b9c56bd95887825a1ce56cde49a2a97c07e28db465d541f5098a2656c" +dependencies = [ + "cocoa", + "objc", + "raw-window-handle", + "windows-sys 0.52.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" +dependencies = [ + "windows-core 0.51.1", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-implement", + "windows-interface", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-implement" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "windows-interface" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "windows-result" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd19df78e5168dfb0aedc343d1d1b8d422ab2db6756d2dc3fef75035402a3f64" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-service" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9db37ecb5b13762d95468a2fc6009d4b2c62801243223aabd44fca13ad13c8" +dependencies = [ + "bitflags 1.3.2", + "widestring", + "windows-sys 0.45.0", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[package]] +name = "windows-version" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75aa004c988e080ad34aff5739c39d0312f4684699d6d71fc8a198d057b8b9b4" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + +[[package]] +name = "winnow" +version = "0.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c830786f7720c2fd27a1a0e27a709dbd3c4d009b56d098fc742d4f4eab91fe2" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "937f3df7948156640f46aacef17a70db0de5917bda9c92b0f751f3a955b588fc" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wry" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ad1bc1d6925e0cde1bd01830b0073cd0448e21357e843b9ede33b6d81c7423" +dependencies = [ + "base64 0.21.5", + "block", + "cfg_aliases", + "cocoa", + "core-graphics 0.23.1", + "crossbeam-channel", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http 0.2.11", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc", + "objc_id", + "once_cell", + "raw-window-handle", + "serde", + "serde_json", + "sha2", + "soup3", + "tao-macros", + "thiserror", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.52.0", + "windows-implement", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" +dependencies = [ + "gethostname 0.3.0", + "nix 0.26.4", + "winapi", + "winapi-wsapoll", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d6c3f9a0fb6701fab8f6cea9b0c0bd5d6876f1f89f7fada07e558077c344bc" +dependencies = [ + "nix 0.26.4", +] + +[[package]] +name = "xattr" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" +dependencies = [ + "libc", + "linux-raw-sys 0.4.12", + "rustix 0.38.30", +] + +[[package]] +name = "xdg-home" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd" +dependencies = [ + "nix 0.26.4", + "winapi", +] + +[[package]] +name = "zbus" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31de390a2d872e4cd04edd71b425e29853f786dc99317ed72d73d6fcf5ebb948" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.26.4", + "once_cell", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d1794a946878c0e807f55a397187c11fc7a038ba5d868e7db4f3bd7760bc9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb80bb776dbda6e23d705cf0123c3b95df99c4ebeaec6c2599d4a5419902b4a9" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.7.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4061bedbb353041c12f413700357bec76df2c7e2ca8e4df8bac24c6bf68e3d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.9+zstd.1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zvariant" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44b291bee0d960c53170780af148dca5fa260a63cdd24f1962fa82e03e53338c" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934d7a7dfc310d6ee06c87ffe88ef4eca7d3e37bb251dece2ef93da8f17d8ecd" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/desktop/tauri/src-tauri/Cargo.toml b/desktop/tauri/src-tauri/Cargo.toml new file mode 100644 index 00000000..6fed0ec3 --- /dev/null +++ b/desktop/tauri/src-tauri/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "app" +version = "0.1.0" +description = "Portmaster UI" +authors = ["Safing"] +license = "" +repository = "" +default-run = "app" +edition = "2021" +rust-version = "1.60" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[build-dependencies] +tauri-build = { version = "2.0.0-alpha", features = [] } + +[dependencies] +# Tauri +tauri = { version = "2.0.0-alpha", features = ["tray-icon", "icon-ico", "icon-png"] } +tauri-plugin-shell = "2.0.0-alpha" +tauri-plugin-dialog = "2.0.0-alpha" +tauri-plugin-clipboard-manager = "2.0.0-alpha" +tauri-plugin-os = "2.0.0-alpha" +tauri-plugin-single-instance = "2.0.0-alpha" +tauri-plugin-cli = "2.0.0-alpha" +tauri-plugin-notification = "2.0.0-alpha" + +# We still need the tauri-cli 1.5 for building +tauri-cli = "1.5.11" + +# General +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +futures-util = { version = "0.3", features = ["sink"] } +dirs = "1.0" +rust-ini = "0.20.0" +dataurl = "0.1.2" +uuid = "1.6.1" +lazy_static = "1.4.0" +tokio = { version = "1.35.0", features = ["macros"] } +cached = "0.46.1" +notify-rust = "4.10.0" +assert_matches = "1.5.0" +tokio-websockets = { version = "0.5.0", features = ["client", "ring", "rand"] } +sha = "1.0.3" +http = "1.0.0" +url = "2.5.0" +thiserror = "1.0" +log = "0.4.21" +pretty_env_logger = "0.5.0" + +# Linux only +[target.'cfg(target_os = "linux")'.dependencies] +glib = "0.18.4" +gtk-sys = "0.18.0" +glib-sys = "0.18.1" +gdk-pixbuf = "0.18.3" +gdk-pixbuf-sys = "0.18.0" +gio-sys = "0.18.1" + +# Windows only +[target.'cfg(target_os = "windows")'.dependencies] +windows-service = "0.6.0" +windows = { version = "0.54.0", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[dev-dependencies] +which = "6.0.0" +gtk = "0.18" +ctor = "0.2.6" + +[features] +# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. +# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. +# DO NOT REMOVE!! +custom-protocol = [ "tauri/custom-protocol" ] diff --git a/desktop/tauri/src-tauri/Cross.toml b/desktop/tauri/src-tauri/Cross.toml new file mode 100644 index 00000000..28e01087 --- /dev/null +++ b/desktop/tauri/src-tauri/Cross.toml @@ -0,0 +1,7 @@ +[target.aarch64-unknown-linux-gnu] +image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main" + +pre-build = [ + "dpkg --add-architecture $CROSS_DEB_ARCH", + "apt-get update && apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH libjavascriptcoregtk-4.0-dev:$CROSS_DEB_ARCH librsvg2-dev libayatana-appindicator3-dev libwebkit2gtk-4.0-dev libsoup2.4-dev libgtk-3-dev" +] diff --git a/desktop/tauri/src-tauri/build.rs b/desktop/tauri/src-tauri/build.rs new file mode 100644 index 00000000..795b9b7c --- /dev/null +++ b/desktop/tauri/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/tauri/src-tauri/src/main.rs b/desktop/tauri/src-tauri/src/main.rs new file mode 100644 index 00000000..b25f8c78 --- /dev/null +++ b/desktop/tauri/src-tauri/src/main.rs @@ -0,0 +1,212 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use tauri::{AppHandle, Manager, RunEvent, WindowEvent}; +use tauri_plugin_cli::CliExt; + +// Library crates +mod portapi; +mod service; + +#[cfg(target_os = "linux")] +mod xdg; + +// App modules +mod portmaster; +mod traymenu; +mod window; + +use log::{debug, error, info, trace, warn}; +use portmaster::PortmasterExt; +use traymenu::setup_tray_menu; +use window::{close_splash_window, create_main_window}; + +#[macro_use] +extern crate lazy_static; + +#[derive(Clone, serde::Serialize)] +struct Payload { + args: Vec, + cwd: String, +} + +struct WsHandler { + handle: AppHandle, + background: bool, + + is_first_connect: bool, +} + +impl portmaster::Handler for WsHandler { + fn name(&self) -> String { + "main-handler".to_string() + } + + fn on_connect(&mut self, cli: portapi::client::PortAPI) -> () { + info!("connection established, creating main window"); + + // we successfully connected to Portmaster. Set is_first_connect to false + // so we don't show the splash-screen when we loose connection. + self.is_first_connect = false; + + if let Err(err) = close_splash_window(&self.handle) { + error!("failed to close splash window: {}", err.to_string()); + } + + // create the main window now. It's not automatically visible by default. + // Rather, the angular application will show the window itself when it finished + // bootstrapping. + if let Err(err) = create_main_window(&self.handle) { + error!("failed to create main window: {}", err.to_string()); + } else { + debug!("created main window") + } + + let handle = self.handle.clone(); + tauri::async_runtime::spawn(async move { + traymenu::tray_handler(cli, handle).await; + }); + } + + fn on_disconnect(&mut self) { + // if we're not running in background and this was the first connection attempt + // then display the splash-screen. + // + // Once we had a successful connection the splash-screen will not be shown anymore + // since there's already a main window with the angular application. + if !self.background && self.is_first_connect { + let _ = window::create_splash_window(&self.handle.clone()); + + self.is_first_connect = false + } + } +} + +fn main() { + pretty_env_logger::init(); + + let app = tauri::Builder::default() + // Shell plugin for open_external support + .plugin(tauri_plugin_shell::init()) + // Clipboard support + .plugin(tauri_plugin_clipboard_manager::init()) + // Dialog (Save/Open) support + .plugin(tauri_plugin_dialog::init()) + // OS Version and Architecture support + .plugin(tauri_plugin_os::init()) + // Single instance guard + .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { + let _ = app.emit("single-instance", Payload { args: argv, cwd }); + })) + // Custom CLI arguments + .plugin(tauri_plugin_cli::init()) + // Notification support + .plugin(tauri_plugin_notification::init()) + // Our Portmaster Plugin that handles communication between tauri and our angular app. + .plugin(portmaster::init()) + // Setup the app an any listeners + .setup(|app| { + setup_tray_menu(app)?; + + // Setup the single-instance event listener that will create/focus the main window + // or the splash-screen. + let handle = app.handle().clone(); + app.listen_global("single-instance", move |_event| { + let _ = window::open_window(&handle); + }); + + // Handle cli flags: + // + let mut background = false; + match app.cli().matches() { + Ok(matches) => { + debug!("cli matches={:?}", matches); + + if let Some(bg_flag) = matches.args.get("background") { + match bg_flag.value.as_bool() { + Some(value) => { + background = value; + app.portmaster().set_show_after_bootstrap(!background); + } + None => {} + } + } + + if let Some(nf_flag) = matches.args.get("with-notifications") { + match nf_flag.value.as_bool() { + Some(v) => { + app.portmaster().with_notification_support(v); + } + None => {} + } + } + + if let Some(pf_flag) = matches.args.get("with-prompts") { + match pf_flag.value.as_bool() { + Some(v) => { + app.portmaster().with_connection_prompts(v); + } + None => {} + } + } + } + Err(err) => { + error!("failed to parse cli arguments: {}", err.to_string()); + } + }; + + // prepare a custom portmaster plugin handler that will show the splash-screen + // (if not in --background) and launch the tray-icon handler. + let handler = WsHandler { + handle: app.handle().clone(), + background, + is_first_connect: true, + }; + + // register the custom handler + app.portmaster().register_handler(handler); + + Ok(()) + }) + .any_thread() + .build(tauri::generate_context!()) + .expect("error while running tauri application"); + + app.run(|handle, e| match e { + RunEvent::WindowEvent { label, event, .. } => { + if label != "main" { + // We only have one window at most so any other label is unexpected + return; + } + + // Do not let the user close the window, instead send an event to the main + // window so we can show the "will not stop portmaster" dialog and let the window + // close itself using + // + // window.__TAURI__.window.getCurrent().close() + // + // Note: the above javascript does NOT trigger the CloseRequested event so + // there's no need to handle that case here. + // + match event { + WindowEvent::CloseRequested { api, .. } => { + debug!( + "window (label={}) close request received, forwarding to user-interface.", + label + ); + + api.prevent_close(); + if let Some(window) = handle.get_window(label.as_str()) { + let _ = window.emit("exit-requested", ""); + } + } + _ => {} + } + } + + RunEvent::ExitRequested { api, .. } => { + api.prevent_exit(); + } + _ => {} + }); +} diff --git a/desktop/tauri/src-tauri/src/portapi/client.rs b/desktop/tauri/src-tauri/src/portapi/client.rs new file mode 100644 index 00000000..b3b00d09 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/client.rs @@ -0,0 +1,191 @@ +use futures_util::{SinkExt, StreamExt}; +use http::Uri; +use log::{debug, error, warn}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::mpsc::{channel, Receiver, Sender}; +use tokio::sync::RwLock; +use tokio_websockets::{ClientBuilder, Error}; + +use super::message::*; +use super::types::*; + +/// An internal representation of a Command that +/// contains the PortAPI message as well as a response +/// channel that will receive all responses sent from the +/// server. +/// +/// Users should normally not need to use the Command struct +/// directly since `PortAPI` already abstracts the creation of +/// mpsc channels. +struct Command { + msg: Message, + response: Sender, +} + +/// The client implementation for PortAPI. +#[derive(Clone)] +pub struct PortAPI { + dispatch: Sender, +} + +/// The map type used to store message subscribers. +type SubscriberMap = RwLock>>; + +/// Connect to PortAPI at the specified URI. +/// +/// This method will launch a new async thread on the `tauri::async_runtime` +/// that will handle message to transmit and also multiplex server responses +/// to the appropriate subscriber. +pub async fn connect(uri: &str) -> Result { + let parsed = match uri.parse::() { + Ok(u) => u, + Err(_e) => { + return Err(Error::NoUriConfigured); // TODO(ppacher): fix the return error type. + } + }; + + let (mut client, _) = ClientBuilder::from_uri(parsed).connect().await?; + let (tx, mut dispatch) = channel::(64); + + tauri::async_runtime::spawn(async move { + let subscribers: SubscriberMap = RwLock::new(HashMap::new()); + let next_id = AtomicUsize::new(0); + + loop { + tokio::select! { + msg = client.next() => { + let msg = match msg { + Some(msg) => msg, + None => { + warn!("websocket connection lost"); + + dispatch.close(); + return; + } + }; + + match msg { + Err(err) => { + error!("failed to receive frame from websocket: {}", err); + + dispatch.close(); + return; + }, + Ok(msg) => { + let text = unsafe { + std::str::from_utf8_unchecked(msg.as_payload()) + }; + + match text.parse::() { + Ok(msg) => { + let id = msg.id; + let map = subscribers + .read() + .await; + + if let Some(sub) = map.get(&id) { + let res: Result = msg.try_into(); + match res { + Ok(response) => { + if let Err(err) = sub.send(response).await { + // The receiver side has been closed already, + // drop the read lock and remove the subscriber + // from our hashmap + drop(map); + + subscribers + .write() + .await + .remove(&id); + + debug!("subscriber for command {} closed read side: {}", id, err); + } + }, + Err(err) => { + error!("invalid command: {}", err); + } + } + } + }, + Err(err) => { + error!("failed to deserialize message: {}", err) + } + } + } + } + + }, + + Some(mut cmd) = dispatch.recv() => { + let id = next_id.fetch_add(1, Ordering::Relaxed); + cmd.msg.id = id; + let blob: String = cmd.msg.into(); + + debug!("Sending websocket frame: {}", blob); + + match client.send(tokio_websockets::Message::text(blob)).await { + Ok(_) => { + subscribers + .write() + .await + .insert(id, cmd.response); + }, + Err(err) => { + error!("failed to dispatch command: {}", err); + + // TODO(ppacher): we should send some error to cmd.response here. + // Otherwise, the sender of cmd might get stuck waiting for responses + // if they don't check for PortAPI.is_closed(). + + return + } + } + } + } + } + }); + + Ok(PortAPI { dispatch: tx }) +} + +impl PortAPI { + /// `request` sends a PortAPI `portapi::types::Request` to the server and returns a mpsc receiver channel + /// where all server responses are forwarded. + /// + /// If the caller does not intend to read any responses the returned receiver may be closed or + /// dropped. As soon as the async-thread launched in `connect` detects a closed receiver it is remove + /// from the subscription map. + /// + /// The default buffer size for the channel is 64. Use `request_with_buffer_size` to specify a dedicated buffer size. + pub async fn request( + &self, + r: Request, + ) -> std::result::Result, MessageError> { + self.request_with_buffer_size(r, 64).await + } + + // Like `request` but supports explicitly specifying a channel buffer size. + pub async fn request_with_buffer_size( + &self, + r: Request, + buffer: usize, + ) -> std::result::Result, MessageError> { + let (tx, rx) = channel(buffer); + + let msg: Message = r.try_into()?; + + let _ = self.dispatch.send(Command { response: tx, msg }).await; + + Ok(rx) + } + + /// Reports whether or not the websocket connection to the Portmaster Database API has been closed + /// due to errors. + /// + /// Users are expected to check this field on a regular interval to detect any issues and perform + /// a clean re-connect by calling `connect` again. + pub fn is_closed(&self) -> bool { + self.dispatch.is_closed() + } +} diff --git a/desktop/tauri/src-tauri/src/portapi/message.rs b/desktop/tauri/src-tauri/src/portapi/message.rs new file mode 100644 index 00000000..46eb7c77 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/message.rs @@ -0,0 +1,258 @@ +use thiserror::Error; + +/// MessageError describes any error that is encountered when parsing +/// PortAPI messages or when converting between the Request/Response types. +#[derive(Debug, Error)] +pub enum MessageError { + #[error("missing command id")] + MissingID, + + #[error("invalid command id")] + InvalidID, + + #[error("missing command")] + MissingCommand, + + #[error("missing key")] + MissingKey, + + #[error("missing payload")] + MissingPayload, + + #[error("unknown or unsupported command: {0}")] + UnknownCommand(String), + + #[error(transparent)] + InvalidPayload(#[from] serde_json::Error), +} + + +/// Payload defines the payload type and content of a PortAPI message. +/// +/// For the time being, only JSON payloads (indicated by a prefixed 'J' of the payload content) +/// is directly supported in `Payload::parse()`. +/// +/// For other payload types (like CBOR, BSON, ...) it's the user responsibility to figure out +/// appropriate decoding from the `Payload::UNKNOWN` variant. +#[derive(PartialEq, Debug, Clone)] +pub enum Payload { + JSON(String), + UNKNOWN(String), +} + +/// ParseError is returned from `Payload::parse()`. +#[derive(Debug, Error)] +pub enum ParseError { + #[error(transparent)] + JSON(#[from] serde_json::Error), + + #[error("unknown error while parsing")] + UNKNOWN +} + + +impl Payload { + /// Parse the payload into T. + /// + /// Only JSON parsing is supported for now. See [Payload] for more information. + pub fn parse<'a, T>(self: &'a Self) -> std::result::Result + where + T: serde::de::Deserialize<'a> { + + match self { + Payload::JSON(blob) => Ok(serde_json::from_str::(blob.as_str())?), + Payload::UNKNOWN(_) => Err(ParseError::UNKNOWN), + } + } +} + +/// Supports creating a Payload instance from a String. +/// +/// See [Payload] for more information. +impl std::convert::From for Payload { + fn from(value: String) -> Payload { + let mut chars = value.chars(); + let first = chars.next(); + let rest = chars.as_str().to_string(); + + match first { + Some(c) => match c { + 'J' => Payload::JSON(rest), + _ => Payload::UNKNOWN(value), + }, + None => Payload::UNKNOWN("".to_string()) + } + } +} + +/// Display implementation for Payload that just displays the raw payload. +impl std::fmt::Display for Payload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Payload::JSON(payload) => { + write!(f, "J{}", payload) + }, + Payload::UNKNOWN(payload) => { + write!(f, "{}", payload) + } + } + } +} + +/// Message is an internal representation of a PortAPI message. +/// Users should more likely use `portapi::types::Request` and `portapi::types::Response` +/// instead of directly using `Message`. +/// +/// The struct is still public since it might be useful for debugging or to implement new +/// commands not yet supported by the `portapi::types` crate. +#[derive(PartialEq, Debug, Clone)] +pub struct Message { + pub id: usize, + pub cmd: String, + pub key: Option, + pub payload: Option, +} + +/// Implementation to marshal a PortAPI message into it's wire-format representation +/// (which is a string). +/// +/// Note that this conversion does not check for invalid messages! +impl std::convert::From for String { + fn from(value: Message) -> Self { + let mut result = "".to_owned(); + + result.push_str(value.id.to_string().as_str()); + result.push_str("|"); + result.push_str(&value.cmd); + + if let Some(key) = value.key { + result.push_str("|"); + result.push_str(key.as_str()); + } + + if let Some(payload) = value.payload { + result.push_str("|"); + result.push_str(payload.to_string().as_str()) + } + + result + } +} + +/// An implementation for `String::parse()` to convert a wire-format representation +/// of a PortAPI message to a Message instance. +/// +/// Any errors returned from `String::parse()` will be of type `MessageError` +impl std::str::FromStr for Message { + type Err = MessageError; + + fn from_str(line: &str) -> Result { + let parts = line.split("|").collect::>(); + + let id = match parts.get(0) { + Some(s) => match (*s).parse::() { + Ok(id) => Ok(id), + Err(_) => Err(MessageError::InvalidID), + }, + None => Err(MessageError::MissingID), + }?; + + let cmd = match parts.get(1) { + Some(s) => Ok(*s), + None => Err(MessageError::MissingCommand), + }? + .to_string(); + + let key = parts.get(2) + .and_then(|key| Some(key.to_string())); + + let payload : Option = parts.get(3) + .and_then(|p| Some(p.to_string().into())); + + return Ok(Message { + id, + cmd, + key, + payload: payload + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, PartialEq, Deserialize)] + struct Test { + a: i64, + s: String, + } + + #[test] + fn payload_to_string() { + let p = Payload::JSON("{}".to_string()); + assert_eq!(p.to_string(), "J{}"); + + let p = Payload::UNKNOWN("some unknown content".to_string()); + assert_eq!(p.to_string(), "some unknown content"); + } + + #[test] + fn payload_from_string() { + let p: Payload = "J{}".to_string().into(); + assert_eq!(p, Payload::JSON("{}".to_string())); + + let p: Payload = "some unknown content".to_string().into(); + assert_eq!(p, Payload::UNKNOWN("some unknown content".to_string())); + } + + #[test] + fn payload_parse() { + let p: Payload = "J{\"a\": 100, \"s\": \"string\"}".to_string().into(); + + let t: Test = p.parse() + .expect("Expected payload parsing to work"); + + assert_eq!(t, Test{ + a: 100, + s: "string".to_string(), + }); + } + + #[test] + fn parse_message() { + let m = "10|insert|some:key|J{}".parse::() + .expect("Expected message to parse"); + + assert_eq!(m, Message{ + id: 10, + cmd: "insert".to_string(), + key: Some("some:key".to_string()), + payload: Some(Payload::JSON("{}".to_string())), + }); + + let m = "1|done".parse::() + .expect("Expected message to parse"); + + assert_eq!(m, Message{ + id: 1, + cmd: "done".to_string(), + key: None, + payload: None + }); + + let m = "".parse::() + .expect_err("Expected parsing to fail"); + if let MessageError::InvalidID = m {} else { + panic!("unexpected error value: {}", m) + } + + let m = "1".parse::() + .expect_err("Expected parsing to fail"); + + if let MessageError::MissingCommand = m {} else { + panic!("unexpected error value: {}", m) + } + } +} diff --git a/desktop/tauri/src-tauri/src/portapi/mod.rs b/desktop/tauri/src-tauri/src/portapi/mod.rs new file mode 100644 index 00000000..67fd2710 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod message; +pub mod types; +pub mod models; \ No newline at end of file diff --git a/desktop/tauri/src-tauri/src/portapi/models/config.rs b/desktop/tauri/src-tauri/src/portapi/models/config.rs new file mode 100644 index 00000000..e29474a8 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/models/config.rs @@ -0,0 +1,18 @@ +use serde::*; +use super::super::message::Payload; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct BooleanValue { + #[serde(rename = "Value")] + pub value: Option, +} + +impl TryInto for BooleanValue { + type Error = serde_json::Error; + + fn try_into(self) -> Result { + let str = serde_json::to_string(&self)?; + + Ok(Payload::JSON(str)) + } +} \ No newline at end of file diff --git a/desktop/tauri/src-tauri/src/portapi/models/mod.rs b/desktop/tauri/src-tauri/src/portapi/models/mod.rs new file mode 100644 index 00000000..91336dd0 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/models/mod.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod spn; +pub mod notification; +pub mod subsystem; \ No newline at end of file diff --git a/desktop/tauri/src-tauri/src/portapi/models/notification.rs b/desktop/tauri/src-tauri/src/portapi/models/notification.rs new file mode 100644 index 00000000..51f4ece4 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/models/notification.rs @@ -0,0 +1,70 @@ +use serde::*; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Notification { + #[serde(rename = "EventID")] + pub event_id: String, + + #[serde(rename = "GUID")] + pub guid: String, + + #[serde(rename = "Type")] + pub notification_type: NotificationType, + + #[serde(rename = "Message")] + pub message: String, + + #[serde(rename = "Title")] + pub title: String, + #[serde(rename = "Category")] + pub category: String, + + #[serde(rename = "EventData")] + pub data: serde_json::Value, + + #[serde(rename = "Expires")] + pub expires: u64, + + #[serde(rename = "State")] + pub state: String, + + #[serde(rename = "AvailableActions")] + pub actions: Vec, + + #[serde(rename = "SelectedActionID")] + pub selected_action_id: String, + + #[serde(rename = "ShowOnSystem")] + pub show_on_system: bool, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Action { + #[serde(rename = "ID")] + pub id: String, + + #[serde(rename = "Text")] + pub text: String, + + #[serde(rename = "Type")] + pub action_type: String, + + #[serde(rename = "Payload")] + pub payload: serde_json::Value, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct NotificationType(i32); + +#[allow(dead_code)] +pub const INFO: NotificationType = NotificationType(0); + +#[allow(dead_code)] +pub const WARN: NotificationType = NotificationType(1); + +#[allow(dead_code)] +pub const PROMPT: NotificationType = NotificationType(2); + +#[allow(dead_code)] +pub const ERROR: NotificationType = NotificationType(3); + diff --git a/desktop/tauri/src-tauri/src/portapi/models/spn.rs b/desktop/tauri/src-tauri/src/portapi/models/spn.rs new file mode 100644 index 00000000..549c2e27 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/models/spn.rs @@ -0,0 +1,8 @@ +use serde::*; + + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct SPNStatus { + #[serde(rename = "Status")] + pub status: String, +} \ No newline at end of file diff --git a/desktop/tauri/src-tauri/src/portapi/models/subsystem.rs b/desktop/tauri/src-tauri/src/portapi/models/subsystem.rs new file mode 100644 index 00000000..c8b0ea27 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/models/subsystem.rs @@ -0,0 +1,45 @@ +#![allow(dead_code)] +use serde::*; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct ModuleStatus { + #[serde(rename = "Name")] + pub name: String, + + #[serde(rename = "Enabled")] + pub enabled: bool, + + #[serde(rename = "Status")] + pub status: u8, + + #[serde(rename = "FailureStatus")] + pub failure_status: u8, + + #[serde(rename = "FailureID")] + pub failure_id: String, + + #[serde(rename = "FailureMsg")] + pub failure_msg: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct Subsystem { + #[serde(rename = "ID")] + pub id: String, + + #[serde(rename = "Name")] + pub name: String, + + #[serde(rename = "Description")] + pub description: String, + + #[serde(rename = "Modules")] + pub module_status: Vec, + + #[serde(rename = "FailureStatus")] + pub failure_status: u8, +} +pub const FAILURE_NONE: u8 = 0; +pub const FAILURE_HINT: u8 = 1; +pub const FAILURE_WARNING: u8 = 2; +pub const FAILURE_ERROR: u8 = 3; diff --git a/desktop/tauri/src-tauri/src/portapi/types.rs b/desktop/tauri/src-tauri/src/portapi/types.rs new file mode 100644 index 00000000..632f24f4 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portapi/types.rs @@ -0,0 +1,199 @@ + +use super::message::*; + +/// Request is a strongly typed request message +/// that can be converted to a `portapi::message::Message` +/// object for further use by the client (`portapi::client::PortAPI`). +#[derive(PartialEq, Debug)] +pub enum Request { + Get(String), + Query(String), + Subscribe(String), + QuerySubscribe(String), + Create(String, Payload), + Update(String, Payload), + Insert(String, Payload), + Delete(String), + Cancel, +} + +/// Implementation to convert a internal `portapi::message::Message` to a valid +/// `Request` variant. +/// +/// Any error returned will be of type `portapi::message::MessageError`. +impl std::convert::TryFrom for Request { + type Error = MessageError; + + fn try_from(value: Message) -> Result { + match value.cmd.as_str() { + "get" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + Ok(Request::Get(key)) + }, + "query" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + Ok(Request::Query(key)) + }, + "sub" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + Ok(Request::Subscribe(key)) + }, + "qsub" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + Ok(Request::QuerySubscribe(key)) + }, + "create" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + let payload = value.payload.ok_or(MessageError::MissingPayload)?; + Ok(Request::Create(key, payload)) + }, + "update" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + let payload = value.payload.ok_or(MessageError::MissingPayload)?; + Ok(Request::Update(key, payload)) + }, + "insert" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + let payload = value.payload.ok_or(MessageError::MissingPayload)?; + Ok(Request::Insert(key, payload)) + }, + "delete" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + Ok(Request::Delete(key)) + }, + "cancel" => { + Ok(Request::Cancel) + }, + cmd => { + Err(MessageError::UnknownCommand(cmd.to_string())) + } + } + } +} + +/// An implementation to try to convert a `Request` variant into a valid +/// `portapi::message::Message` struct. +/// +/// While this implementation does not yet return any errors, it's expected that +/// additional validation will be added in the future so users should already expect +/// to receive `portapi::message::MessageError`s. +impl std::convert::TryFrom for Message { + type Error = MessageError; + + fn try_from(value: Request) -> Result { + match value { + Request::Get(key) => Ok(Message { id: 0, cmd: "get".to_string(), key: Some(key), payload: None }), + Request::Query(key) => Ok(Message { id: 0, cmd: "query".to_string(), key: Some(key), payload: None }), + Request::Subscribe(key) => Ok(Message { id: 0, cmd: "sub".to_string(), key: Some(key), payload: None }), + Request::QuerySubscribe(key) => Ok(Message { id: 0, cmd: "qsub".to_string(), key: Some(key), payload: None }), + Request::Create(key, value) => Ok(Message{ id: 0, cmd: "create".to_string(), key: Some(key), payload: Some(value)}), + Request::Update(key, value) => Ok(Message{ id: 0, cmd: "update".to_string(), key: Some(key), payload: Some(value)}), + Request::Insert(key, value) => Ok(Message{ id: 0, cmd: "insert".to_string(), key: Some(key), payload: Some(value)}), + Request::Delete(key) => Ok(Message { id: 0, cmd: "delete".to_string(), key: Some(key), payload: None }), + Request::Cancel => Ok(Message { id: 0, cmd: "cancel".to_string(), key: None, payload: None }), + } + } +} + + +/// Response is strongly types PortAPI response message. +/// that can be converted to a `portapi::message::Message` +/// object for further use by the client (`portapi::client::PortAPI`). +#[derive(PartialEq, Debug)] +pub enum Response { + Ok(String, Payload), + Update(String, Payload), + New(String, Payload), + Delete(String), + Success, + Error(String), + Warning(String), + Done +} + +/// Implementation to convert a internal `portapi::message::Message` to a valid +/// `Response` variant. +/// +/// Any error returned will be of type `portapi::message::MessageError`. +impl std::convert::TryFrom for Response { + type Error = MessageError; + + fn try_from(value: Message) -> Result { + match value.cmd.as_str() { + "ok" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + let payload = value.payload.ok_or(MessageError::MissingPayload)?; + + Ok(Response::Ok(key, payload)) + }, + "upd" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + let payload = value.payload.ok_or(MessageError::MissingPayload)?; + + Ok(Response::Update(key, payload)) + }, + "new" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + let payload = value.payload.ok_or(MessageError::MissingPayload)?; + + Ok(Response::New(key, payload)) + }, + "del" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + + Ok(Response::Delete(key)) + }, + "success" => { + Ok(Response::Success) + }, + "error" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + + Ok(Response::Error(key)) + }, + "warning" => { + let key = value.key.ok_or(MessageError::MissingKey)?; + + Ok(Response::Warning(key)) + }, + "done" => { + Ok(Response::Done) + }, + cmd => Err(MessageError::UnknownCommand(cmd.to_string())) + } + } +} + +/// An implementation to try to convert a `Response` variant into a valid +/// `portapi::message::Message` struct. +/// +/// While this implementation does not yet return any errors, it's expected that +/// additional validation will be added in the future so users should already expect +/// to receive `portapi::message::MessageError`s. +impl std::convert::TryFrom for Message { + type Error = MessageError; + + fn try_from(value: Response) -> Result { + match value { + Response::Ok(key, payload) => Ok(Message{id: 0, cmd: "ok".to_string(), key: Some(key), payload: Some(payload)}), + Response::Update(key, payload) => Ok(Message{id: 0, cmd: "upd".to_string(), key: Some(key), payload: Some(payload)}), + Response::New(key, payload) => Ok(Message{id: 0, cmd: "new".to_string(), key: Some(key), payload: Some(payload)}), + Response::Delete(key ) => Ok(Message{id: 0, cmd: "del".to_string(), key: Some(key), payload: None}), + Response::Success => Ok(Message{id: 0, cmd: "success".to_string(), key: None, payload: None}), + Response::Warning(key) => Ok(Message{id: 0, cmd: "warning".to_string(), key: Some(key), payload: None}), + Response::Error(key) => Ok(Message{id: 0, cmd: "error".to_string(), key: Some(key), payload: None}), + Response::Done => Ok(Message{id: 0, cmd: "done".to_string(), key: None, payload: None}), + } + } +} + + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub struct Record { + pub created: u64, + pub deleted: u64, + pub expires: u64, + pub modified: u64, + pub key: String, +} \ No newline at end of file diff --git a/desktop/tauri/src-tauri/src/portmaster/commands.rs b/desktop/tauri/src-tauri/src/portmaster/commands.rs new file mode 100644 index 00000000..dfa2b222 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portmaster/commands.rs @@ -0,0 +1,182 @@ +use super::PortmasterPlugin; +use crate::service::get_service_manager; +use crate::service::ServiceManager; +use log::debug; +use std::sync::atomic::Ordering; +use tauri::{Manager, Runtime, State, Window}; + +pub type Result = std::result::Result; + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct Error { + pub error: String, +} + +#[tauri::command] +pub fn should_show( + _window: Window, + portmaster: State<'_, PortmasterPlugin>, +) -> Result { + if portmaster.get_show_after_bootstrap() { + debug!("[tauri:rpc:should_show] application should show after bootstrap"); + + Ok("show".to_string()) + } else { + debug!("[tauri:rpc:should_show] application should hide after bootstrap"); + + Ok("hide".to_string()) + } +} + +#[tauri::command] +pub fn should_handle_prompts( + _window: Window, + portmaster: State<'_, PortmasterPlugin>, +) -> Result { + if portmaster.handle_prompts.load(Ordering::Relaxed) { + Ok("true".to_string()) + } else { + Ok("false".to_string()) + } +} + +#[tauri::command] +pub fn get_state( + _window: Window, + portmaster: State<'_, PortmasterPlugin>, + key: String, +) -> Result { + let value = portmaster.get_state(key); + + if let Some(value) = value { + Ok(value) + } else { + Ok("".to_string()) + } +} + +#[tauri::command] +pub fn set_state( + _window: Window, + portmaster: State<'_, PortmasterPlugin>, + key: String, + value: String, +) -> Result { + portmaster.set_state(key, value); + + Ok("".to_string()) +} + +#[cfg(target_os = "linux")] +#[tauri::command] +pub fn get_app_info( + window: Window, + response_id: String, + matching_path: String, + exec_path: String, + pid: i64, + cmdline: String, +) -> Result { + let mut id = response_id; + + let info = crate::xdg::ProcessInfo { + cmdline, + exec_path, + pid, + matching_path, + }; + + if id == "" { + id = uuid::Uuid::new_v4().to_string() + } + let cloned = id.clone(); + + std::thread::spawn(move || match crate::xdg::get_app_info(info) { + Ok(info) => window.emit(&id, info), + Err(err) => window.emit( + &id, + Error { + error: err.to_string(), + }, + ), + }); + + Ok(cloned) +} + +#[cfg(target_os = "windows")] +#[tauri::command] +pub fn get_app_info( + window: Window, + response_id: String, + _matching_path: String, + _exec_path: String, + _pid: i64, + _cmdline: String, +) -> Result { + let mut id = response_id; + + if id == "" { + id = uuid::Uuid::new_v4().to_string() + } + let cloned = id.clone(); + + std::thread::spawn(move || { + let _ = window.emit( + &id, + Error { + error: "Unsupported OS".to_string(), + }, + ); + }); + + Ok(cloned) +} + +#[tauri::command] +pub fn get_service_manager_status(window: Window, response_id: String) -> Result { + let mut id = response_id; + + if id == "" { + id = uuid::Uuid::new_v4().to_string(); + } + let cloned = id.clone(); + + std::thread::spawn(move || { + let result = match get_service_manager() { + Ok(sm) => sm.status().map_err(|err| err.to_string()), + Err(err) => Err(err.to_string()), + }; + + match result { + Ok(result) => window.emit(&id, &result), + Err(err) => window.emit(&id, Error { error: err }), + } + }); + + Ok(cloned) +} + +#[tauri::command] +pub fn start_service(window: Window, response_id: String) -> Result { + let mut id = response_id; + + if id == "" { + id = uuid::Uuid::new_v4().to_string(); + } + let cloned = id.clone(); + + std::thread::spawn(move || { + let result = match get_service_manager() { + Ok(sm) => sm.start().map_err(|err| err.to_string()), + Err(err) => Err(err.to_string()), + }; + + match result { + Ok(result) => window.emit(&id, &result), + Err(err) => window.emit(&id, Error { error: err }), + } + }); + + Ok(cloned) +} diff --git a/desktop/tauri/src-tauri/src/portmaster/mod.rs b/desktop/tauri/src-tauri/src/portmaster/mod.rs new file mode 100644 index 00000000..aecf1874 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portmaster/mod.rs @@ -0,0 +1,308 @@ +/// This module contains a custom tauri plugin that handles all communication +/// with the angular app loaded from the portmaster api. +/// +/// Using a custom-plugin for this has the advantage that all code that has +/// access to a tauri::Window or a tauri::AppHandle can get access to the +/// portmaster plugin using the Runtime/Manager extension by just calling +/// window.portmaster() or app_handle.portmaster(). +/// +/// Any portmaster related features (like changing a portmaster setting) should +/// live in this module. +/// +/// Code that handles windows should NOT live here but should rather be placed +/// in the crate root. +// The commands module contains tauri commands that are available to Javascript +// using the invoke() and our custom invokeAsync() command. +mod commands; + +// The websocket module spawns an async function on tauri's runtime that manages +// a persistent connection to the Portmaster websocket API and updates the tauri Portmaster +// Plugin instance. +mod websocket; + +// The notification module manages system notifications from portmaster. +mod notifications; + +use crate::portapi::{ + client::PortAPI, message::Payload, models::config::BooleanValue, types::Request, +}; +use std::{ + collections::HashMap, + sync::atomic::{AtomicBool, Ordering}, +}; + +use log::{debug, error}; +use serde; +use std::sync::Mutex; +use tauri::{ + plugin::{Builder, TauriPlugin}, + AppHandle, Manager, Runtime, +}; + +pub trait Handler { + fn on_connect(&mut self, cli: PortAPI) -> (); + fn on_disconnect(&mut self); + fn name(&self) -> String; +} + +pub struct PortmasterPlugin { + #[allow(dead_code)] + app: AppHandle, + + // state allows the angular application to store arbitrary values in the + // tauri application memory using the get_state and set_state + // tauri::commands. + state: Mutex>, + + // an atomic boolean that indicates if we're currently connected to + // portmaster or not. + is_reachable: AtomicBool, + + // holds the portapi client if any. + api: Mutex>, + + // a vector of handlers that should be invoked on connect and disconnect of + // the portmaster API. + handlers: Mutex>>, + + // whether or not we should handle notifications here. + handle_notifications: AtomicBool, + + // whether or not we should handle prompts. + handle_prompts: AtomicBool, + + // whether or not the angular application should call window.show after it + // finished bootstrapping. + should_show_after_bootstrap: AtomicBool, +} + +impl PortmasterPlugin { + /// Returns a state stored in the portmaster plugin. + pub fn get_state(&self, key: String) -> Option { + let map = self.state.lock(); + + if let Ok(map) = map { + match map.get(&key) { + Some(value) => Some(value.clone()), + None => None, + } + } else { + None + } + } + + /// Adds a new state to the portmaster plugin. + pub fn set_state(&self, key: String, value: String) { + let map = self.state.lock(); + + if let Ok(mut map) = map { + map.insert(key, value); + } + } + + /// Reports wheter or not we're currently connected to the Portmaster API. + pub fn is_reachable(&self) -> bool { + self.is_reachable.load(Ordering::Relaxed) + } + + /// Registers a new connection handler that is called on connect + /// and disconnect of the Portmaster websocket API. + pub fn register_handler(&self, mut handler: impl Handler + Send + 'static) { + if let Ok(mut handlers) = self.handlers.lock() { + // register_handler can only be invoked after the plugin setup + // completed. in this case, the websocket thread is already spawned and + // we might already be connected or know that the connection failed. + // Call the respective handler method immediately now. + if let Some(api) = self.get_api() { + debug!("already connected to Portmaster API, calling on_connect()"); + + handler.on_connect(api); + } else { + debug!("not yet connected to Portmaster API, calling on_disconnect()"); + + handler.on_disconnect(); + } + + handlers.push(Box::new(handler)); + + debug!("number of registered handlers: {}", handlers.len()); + } + } + + /// Returns the current portapi client. + pub fn get_api(&self) -> Option { + if let Ok(mut api) = self.api.lock() { + match &mut *api { + Some(api) => Some(api.clone()), + None => None, + } + } else { + None + } + } + + /// Feature functions (enable/disable certain features). + + /// Configures whether or not our tauri app should show system + /// notifications. This excludes connection prompts. Use + /// with_connection_prompts to enable handling of connection prompts. + pub fn with_notification_support(&self, enable: bool) { + self.handle_notifications.store(enable, Ordering::Relaxed); + + // kick of the notification handler if we are connected. + if enable { + self.start_notification_handler(); + } + } + + /// Configures whether or not our angular application should show connection + /// prompts via tauri. + pub fn with_connection_prompts(&self, enable: bool) { + self.handle_prompts.store(enable, Ordering::Relaxed); + } + + /// Whether or not the angular application should call window.show after it + /// finished bootstrapping. + pub fn set_show_after_bootstrap(&self, show: bool) { + self.should_show_after_bootstrap + .store(show, Ordering::Relaxed); + } + + /// Returns whether or not the angular application should call window.show + /// after it finished bootstrapping. + pub fn get_show_after_bootstrap(&self) -> bool { + self.should_show_after_bootstrap.load(Ordering::Relaxed) + } + + /// Tells the angular applicatoin to show the window by emitting an event. + /// It calls set_show_after_bootstrap(true) automatically so the application + /// also shows after bootstrapping. + pub fn show_window(&self) { + debug!("[tauri] showing main window"); + + // set show_after_bootstrap to true so the app will even show if it + // misses the event below because it's still bootstrapping. + self.set_show_after_bootstrap(true); + + // ignore the error here, there's nothing we could do about it anyways. + let _ = self.app.emit("portmaster:show", ""); + } + + /// Enables or disables the SPN. + pub fn set_spn_enabled(&self, enabled: bool) { + if let Some(api) = self.get_api() { + let body: Result = BooleanValue { + value: Some(enabled), + } + .try_into(); + + if let Ok(payload) = body { + tauri::async_runtime::spawn(async move { + _ = api + .request(Request::Update("config:spn/enable".to_string(), payload)) + .await; + }); + } + } + } + + //// Internal functions + fn start_notification_handler(&self) { + if let Some(api) = self.get_api() { + let cli = api.clone(); + tauri::async_runtime::spawn(async move { + notifications::notification_handler(cli).await; + }); + } + } + + /// Internal method to call all on_connect handlers + fn on_connect(&self, api: PortAPI) { + debug!("connection to portmaster established, calling handlers"); + + self.is_reachable.store(true, Ordering::Relaxed); + + // store the new api client. + let mut guard = self.api.lock().unwrap(); + *guard = Some(api.clone()); + drop(guard); + + // fire-off the notification handler. + if self.handle_notifications.load(Ordering::Relaxed) { + self.start_notification_handler(); + } + + if let Ok(mut handlers) = self.handlers.lock() { + debug!("executing handler.on_connect()"); + + for handler in handlers.iter_mut() { + debug!("calling registered handler: {}", handler.name()); + handler.on_connect(api.clone()); + } + } else { + error!("failed to lock handlers") + } + } + + /// Internal method to call all on_disconnect handlers + fn on_disconnect(&self) { + self.is_reachable.store(false, Ordering::Relaxed); + + // clear the current api client reference. + let mut guard = self.api.lock().unwrap(); + *guard = None; + drop(guard); + + if let Ok(mut handlers) = self.handlers.lock() { + for handler in handlers.iter_mut() { + handler.on_disconnect(); + } + } + } +} + +pub trait PortmasterExt { + fn portmaster(&self) -> &PortmasterPlugin; +} + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +pub struct Config {} + +impl> PortmasterExt for T { + fn portmaster(&self) -> &PortmasterPlugin { + self.state::>().inner() + } +} + +pub fn init() -> TauriPlugin> { + Builder::>::new("portmaster") + .invoke_handler(tauri::generate_handler![ + commands::get_app_info, + commands::get_service_manager_status, + commands::start_service, + commands::get_state, + commands::set_state, + commands::should_show, + commands::should_handle_prompts + ]) + .setup(|app, _api| { + let plugin = PortmasterPlugin { + app: app.clone(), + state: Mutex::new(HashMap::new()), + is_reachable: AtomicBool::new(false), + handlers: Mutex::new(Vec::new()), + api: Mutex::new(None), + handle_notifications: AtomicBool::new(false), + handle_prompts: AtomicBool::new(false), + should_show_after_bootstrap: AtomicBool::new(true), + }; + + app.manage(plugin); + + // fire of the websocket handler + websocket::start_websocket_thread(app.clone()); + + Ok(()) + }) + .build() +} diff --git a/desktop/tauri/src-tauri/src/portmaster/notifications.rs b/desktop/tauri/src-tauri/src/portmaster/notifications.rs new file mode 100644 index 00000000..88472639 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portmaster/notifications.rs @@ -0,0 +1,103 @@ +use crate::portapi::client::*; +use crate::portapi::message::*; +use crate::portapi::models::notification::*; +use crate::portapi::types::*; +use log::error; +use notify_rust; +use serde_json::json; +#[allow(unused_imports)] +use tauri::async_runtime; + +pub async fn notification_handler(cli: PortAPI) { + let res = cli + .request(Request::QuerySubscribe("query notifications:".to_string())) + .await; + + if let Ok(mut rx) = res { + while let Some(msg) = rx.recv().await { + let res = match msg { + Response::Ok(key, payload) => Some((key, payload)), + Response::New(key, payload) => Some((key, payload)), + Response::Update(key, payload) => Some((key, payload)), + _ => None, + }; + + if let Some((key, payload)) = res { + match payload.parse::() { + Ok(n) => { + // Skip if this one should not be shown using the system notifications + if !n.show_on_system { + return; + } + + // Skip if this action has already been acted on + if n.selected_action_id != "" { + return; + } + + // TODO(ppacher): keep a reference of open notifications and close them + // if the user reacted inside the UI: + + let mut notif = notify_rust::Notification::new(); + notif.body(&n.message); + notif.timeout(notify_rust::Timeout::Never); // TODO(ppacher): use n.expires to calculate the timeout. + notif.summary(&n.title); + notif.icon("portmaster"); + + for action in n.actions { + notif.action(&action.id, &action.text); + } + + #[cfg(target_os = "linux")] + { + let cli_clone = cli.clone(); + async_runtime::spawn(async move { + let res = notif.show(); + match res { + Ok(handle) => { + handle.wait_for_action(|action| { + match action { + "__closed" => { + // timeout + } + + value => { + let value = value.to_string().clone(); + + async_runtime::spawn(async move { + let _ = cli_clone + .request(Request::Update( + key, + Payload::JSON( + json!({ + "SelectedActionID": value + }) + .to_string(), + ), + )) + .await; + }); + } + } + }) + } + Err(err) => { + error!("failed to display notification: {}", err); + } + } + }); + } + } + Err(err) => match err { + ParseError::JSON(err) => { + error!("failed to parse notification: {}", err); + } + _ => { + error!("unknown error when parsing notifications payload"); + } + }, + } + } + } + } +} diff --git a/desktop/tauri/src-tauri/src/portmaster/websocket.rs b/desktop/tauri/src-tauri/src/portmaster/websocket.rs new file mode 100644 index 00000000..6dca8519 --- /dev/null +++ b/desktop/tauri/src-tauri/src/portmaster/websocket.rs @@ -0,0 +1,45 @@ +use super::PortmasterExt; +use crate::portapi::client::connect; +use log::{debug, error, info, warn}; +use tauri::{AppHandle, Runtime}; +use tokio::time::{sleep, Duration}; + +/// Starts a backround thread (via tauri::async_runtime) that connects to the Portmaster +/// Websocket database API. +pub fn start_websocket_thread(app: AppHandle) { + let app = app.clone(); + + tauri::async_runtime::spawn(async move { + loop { + debug!("Trying to connect to websocket endpoint"); + + let api = connect("ws://127.0.0.1:817/api/database/v1").await; + + match api { + Ok(cli) => { + let portmaster = app.portmaster(); + + info!("Successfully connected to portmaster"); + + portmaster.on_connect(cli.clone()); + + while !cli.is_closed() { + let _ = sleep(Duration::from_secs(1)).await; + } + + portmaster.on_disconnect(); + + warn!("lost connection to portmaster, retrying ....") + } + Err(err) => { + error!("failed to create portapi client: {}", err); + + app.portmaster().on_disconnect(); + + // sleep and retry + sleep(Duration::from_secs(2)).await; + } + } + } + }); +} diff --git a/desktop/tauri/src-tauri/src/service/manager.rs b/desktop/tauri/src-tauri/src/service/manager.rs new file mode 100644 index 00000000..7a79e061 --- /dev/null +++ b/desktop/tauri/src-tauri/src/service/manager.rs @@ -0,0 +1,17 @@ +use std::process::{Command, ExitStatus, Stdio}; +use std::{fs, io}; + +use thiserror::Error; + +#[cfg(target_os = "linux")] +use std::os::unix::fs::PermissionsExt; + +use super::status::StatusResult; + +static SYSTEMCTL: &str = "systemctl"; +// TODO(ppacher): add support for kdesudo and gksudo + +enum SudoCommand { + Pkexec, + Gksu, +} diff --git a/desktop/tauri/src-tauri/src/service/mod.rs b/desktop/tauri/src-tauri/src/service/mod.rs new file mode 100644 index 00000000..ac55a39f --- /dev/null +++ b/desktop/tauri/src-tauri/src/service/mod.rs @@ -0,0 +1,76 @@ +// pub mod manager; +pub mod status; + +#[cfg(target_os = "linux")] +mod systemd; + +#[cfg(target_os = "windows")] +mod windows_service; + +use std::process::ExitStatus; + +#[cfg(target_os = "linux")] +use crate::service::systemd::SystemdServiceManager; + +use log::info; +use thiserror::Error; + +use self::status::StatusResult; + +#[allow(dead_code)] +#[derive(Error, Debug)] +pub enum ServiceManagerError { + #[error("unsupported service manager")] + UnsupportedServiceManager, + + #[error("unsupported operating system")] + UnsupportedOperatingSystem, + + #[error(transparent)] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error("{0} output={1}")] + Other(ExitStatus, String), + + #[error("{0}")] + WindowsError(String), +} + +pub type Result = std::result::Result; + +/// A common interface to the system manager service (might be systemd, openrc, sc.exe, ...) +pub trait ServiceManager { + fn status(&self) -> Result; + fn start(&self) -> Result; +} + +struct EmptyServiceManager(); + +impl ServiceManager for EmptyServiceManager { + fn status(&self) -> Result { + Err(ServiceManagerError::UnsupportedServiceManager) + } + + fn start(&self) -> Result { + Err(ServiceManagerError::UnsupportedServiceManager) + } +} + +pub fn get_service_manager() -> Result { + #[cfg(target_os = "linux")] + { + if SystemdServiceManager::is_installed() { + info!("system service manager: systemd"); + + Ok(SystemdServiceManager {}) + } else { + Err(ServiceManagerError::UnsupportedServiceManager) + } + } + + #[cfg(target_os = "windows")] + return Ok(windows_service::SERVICE_MANGER.clone()); +} diff --git a/desktop/tauri/src-tauri/src/service/status.rs b/desktop/tauri/src-tauri/src/service/status.rs new file mode 100644 index 00000000..30f4841d --- /dev/null +++ b/desktop/tauri/src-tauri/src/service/status.rs @@ -0,0 +1,27 @@ +use serde::{Serialize, Deserialize}; + +/// SystemResult defines the "success" codes when querying or starting +/// a system service. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub enum StatusResult { + // The requested system service is installed and currently running. + Running, + + // The requested system service is installed but currently stopped. + Stopped, + + // NotFound is returned when the system service (systemd unit for linux) + // has not been found and the system and likely means the Portmaster installtion + // is broken all together. + NotFound, +} + +impl std::fmt::Display for StatusResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StatusResult::Running => write!(f, "running"), + StatusResult::Stopped => write!(f, "stopped"), + StatusResult::NotFound => write!(f, "not installed") + } + } +} diff --git a/desktop/tauri/src-tauri/src/service/systemd.rs b/desktop/tauri/src-tauri/src/service/systemd.rs new file mode 100644 index 00000000..770a9139 --- /dev/null +++ b/desktop/tauri/src-tauri/src/service/systemd.rs @@ -0,0 +1,246 @@ +use log::{debug, error}; + +use super::status::StatusResult; +use super::{Result, ServiceManager, ServiceManagerError}; +use std::os::unix::fs::PermissionsExt; +use std::{ + fs, io, + process::{Command, ExitStatus, Stdio}, +}; + +static SYSTEMCTL: &str = "systemctl"; +// TODO(ppacher): add support for kdesudo and gksudo + +enum SudoCommand { + Pkexec, + Gksu, +} + +impl From for ServiceManagerError { + fn from(output: std::process::Output) -> Self { + let msg = String::from_utf8(output.stderr) + .ok() + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + String::from_utf8(output.stdout) + .ok() + .filter(|s| !s.trim().is_empty()) + }) + .unwrap_or_else(|| format!("Failed to run `systemctl`")); + + ServiceManagerError::Other(output.status, msg) + } +} + +/// System Service manager implementation for Linux based distros. +pub struct SystemdServiceManager {} + +impl SystemdServiceManager { + /// Checks if systemctl is available in /sbin/ /bin, /usr/bin or /usr/sbin. + /// + /// Note that we explicitly check those paths to avoid returning true in case + /// there's a systemctl binary in the cwd and PATH includes . since this may + /// pose a security risk of running an untrusted binary with root privileges. + pub fn is_installed() -> bool { + let paths = vec![ + "/sbin/systemctl", + "/bin/systemctl", + "/usr/sbin/systemctl", + "/usr/bin/systemctl", + ]; + + for path in paths { + debug!("checking for systemctl at path {}", path); + + match fs::metadata(path) { + Ok(md) => { + debug!("found systemctl at path {} ", path); + + if md.is_file() && md.permissions().mode() & 0o111 != 0 { + return true; + } + + error!( + "systemctl binary found but invalid permissions: {}", + md.permissions().mode().to_string() + ); + } + Err(err) => { + error!( + "failed to check systemctl binary at {}: {}", + path, + err.to_string() + ); + + continue; + } + }; + } + + error!("failed to find systemctl binary"); + + false + } +} + +impl ServiceManager for SystemdServiceManager { + fn status(&self) -> super::Result { + let name = "portmaster.service"; + let result = systemctl("is-active", name, false); + + match result { + // If `systemctl is-active` returns without an error code and stdout matches "active" (just to guard againt + // unhandled cases), the service can be considered running. + Ok(stdout) => { + let mut copy = stdout.to_owned(); + trim_newline(&mut copy); + + if copy != "active" { + // make sure the output is as we expected + Err(ServiceManagerError::Other(ExitStatus::default(), stdout)) + } else { + Ok(StatusResult::Running) + } + } + + Err(e) => { + if let ServiceManagerError::Other(_err, ref output) = e { + let mut copy = output.to_owned(); + trim_newline(&mut copy); + + if copy == "inactive" { + return Ok(StatusResult::Stopped); + } + } else { + error!("failed to run 'systemctl is-active': {}", e.to_string()); + } + + // Failed to check if the unit is running + match systemctl("cat", name, false) { + // "systemctl cat" seems to no have stable exit codes so we need + // to check the output if it looks like "No files found for yyyy.service" + // At least, the exit code are not documented for systemd v255 (newest at the time of writing) + Err(ServiceManagerError::Other(status, msg)) => { + if msg.contains("No files found for") { + Ok(StatusResult::NotFound) + } else { + Err(ServiceManagerError::Other(status, msg)) + } + } + + // Any other error type means something went completely wrong while running systemctl altogether. + Err(e) => Err(e), + + // Fine, systemctl cat worked so if the output is "inactive" we know the service is installed + // but stopped. + Ok(_) => { + // Unit seems to be installed so check the output of result + let mut stderr = e.to_string(); + trim_newline(&mut stderr); + + if stderr == "inactive" { + Ok(StatusResult::Stopped) + } else { + Err(e) + } + } + } + } + } + } + + fn start(&self) -> Result { + let name = "portmaster.service"; + + // This time we need to run as root through pkexec or similar binaries like kdesudo/gksudo. + systemctl("start", name, true)?; + + // Check the status again to be sure it's started now + self.status() + } +} + +fn systemctl( + cmd: &str, + unit: &str, + run_as_root: bool, +) -> std::result::Result { + let output = run(run_as_root, SYSTEMCTL, vec![cmd, unit])?; + + // The command have been able to run (i.e. has been spawned and executed by the kernel). + // We now need to check the exit code and "stdout/stderr" output in case of an error. + if output.status.success() { + Ok(String::from_utf8(output.stdout)?) + } else { + Err(output.into()) + } +} + +fn run<'a>(root: bool, cmd: &'a str, args: Vec<&'a str>) -> std::io::Result { + // clone the args vector so we can insert the actual command in case we're running + // through pkexec or friends. This is just callled a couple of times on start-up + // so cloning the vector does not add any mentionable performance impact here and it's better + // than expecting a mutalble vector in the first place. + + let mut args = args.to_vec(); + + let mut command = match root { + true => { + // if we run through pkexec and friends we need to append cmd as the second argument. + + args.insert(0, cmd); + match get_sudo_cmd() { + Ok(cmd) => { + match cmd { + SudoCommand::Pkexec => { + // disable the internal text-based prompt agent from pkexec because it won't work anyway. + args.insert(0, "--disable-internal-agent"); + Command::new("/usr/bin/pkexec") + } + SudoCommand::Gksu => { + args.insert(0, "--message=Please enter your password:"); + args.insert(1, "--sudo-mode"); + + Command::new("/usr/bin/gksudo") + } + } + } + Err(err) => return Err(err), + } + } + false => Command::new(cmd), + }; + + command.env("LC_ALL", "C"); + + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + command.args(args).output() +} + +fn trim_newline(s: &mut String) { + if s.ends_with('\n') { + s.pop(); + if s.ends_with('\r') { + s.pop(); + } + } +} + +fn get_sudo_cmd() -> std::result::Result { + if let Ok(_) = fs::metadata("/usr/bin/pkexec") { + return Ok(SudoCommand::Pkexec); + } + + if let Ok(_) = fs::metadata("/usr/bin/gksudo") { + return Ok(SudoCommand::Gksu); + } + + Err(std::io::Error::new( + io::ErrorKind::NotFound, + "failed to detect sudo command", + )) +} diff --git a/desktop/tauri/src-tauri/src/service/windows_service.rs b/desktop/tauri/src-tauri/src/service/windows_service.rs new file mode 100644 index 00000000..2146cc41 --- /dev/null +++ b/desktop/tauri/src-tauri/src/service/windows_service.rs @@ -0,0 +1,167 @@ +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use windows::{ + core::{HSTRING, PCWSTR}, + Win32::{Foundation::HWND, UI::WindowsAndMessaging::SHOW_WINDOW_CMD}, +}; +use windows_service::{ + service::{Service, ServiceAccess}, + service_manager::{ServiceManager, ServiceManagerAccess}, +}; + +const SERVICE_NAME: &str = "PortmasterCore"; + +pub struct WindowsServiceManager { + manager: Option, + service: Option, +} + +lazy_static! { + pub static ref SERVICE_MANGER: Arc> = + Arc::new(Mutex::new(WindowsServiceManager::new())); +} + +impl WindowsServiceManager { + pub fn new() -> Self { + Self { + manager: None, + service: None, + } + } + + fn init_manager(&mut self) -> super::Result<()> { + // Initialize service manager. This connects to the active service database and can query status. + let manager = match ServiceManager::local_computer( + None::<&str>, + ServiceManagerAccess::ENUMERATE_SERVICE, // Only query status is allowed form non privileged application. + ) { + Ok(manager) => manager, + Err(err) => { + return Err(windows_to_manager_err(err)); + } + }; + self.manager = Some(manager); + Ok(()) + } + + fn open_service(&mut self) -> super::Result { + if let None = self.manager { + self.init_manager()?; + } + + if let Some(manager) = &self.manager { + let service = match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) { + Ok(service) => service, + Err(_) => { + return Ok(false); // Service is not installed. + } + }; + // Service is installed and the state can be queried. + self.service = Some(service); + return Ok(true); + } + + return Err(super::ServiceManagerError::WindowsError( + "failed to initialize manager".to_string(), + )); + } +} + +impl super::ServiceManager for Arc> { + fn status(&self) -> super::Result { + if let Ok(mut manager) = self.lock() { + if let None = manager.service { + // Try to open service + if !manager.open_service()? { + // Service is not installed. + return Ok(super::status::StatusResult::NotFound); + } + } + + if let Some(service) = &manager.service { + match service.query_status() { + Ok(status) => match status.current_state { + windows_service::service::ServiceState::Stopped + | windows_service::service::ServiceState::StopPending + | windows_service::service::ServiceState::PausePending + | windows_service::service::ServiceState::StartPending + | windows_service::service::ServiceState::ContinuePending + | windows_service::service::ServiceState::Paused => { + // Stopped or in a transition state. + return Ok(super::status::StatusResult::Stopped); + } + windows_service::service::ServiceState::Running => { + // Everything expect Running state is considered stopped. + return Ok(super::status::StatusResult::Running); + } + }, + Err(err) => { + return Err(super::ServiceManagerError::WindowsError(err.to_string())); + } + } + } + } + // This should be unreachable. + Ok(super::status::StatusResult::NotFound) + } + + fn start(&self) -> super::Result { + if let Ok(mut service_manager) = self.lock() { + // Check if service is installed. + if let None = &service_manager.service { + if let Err(_) = service_manager.open_service() { + return Ok(super::status::StatusResult::NotFound); + } + } + + // Run service manager with elevated privileges. This will show access popup. + unsafe { + windows::Win32::UI::Shell::ShellExecuteW( + HWND::default(), + &HSTRING::from("runas"), + &HSTRING::from("C:\\Windows\\System32\\sc.exe"), + &HSTRING::from(format!("start {}", SERVICE_NAME)), + PCWSTR::null(), + SHOW_WINDOW_CMD(0), + ); + } + + // Wait for service to start. Timeout 10s (100 * 100ms). + if let Some(service) = &service_manager.service { + for _ in 0..100 { + match service.query_status() { + Ok(status) => { + if let windows_service::service::ServiceState::Running = + status.current_state + { + return Ok(super::status::StatusResult::Running); + } else { + std::thread::sleep(Duration::from_millis(100)); + } + } + Err(err) => return Err(windows_to_manager_err(err)), + } + } + } + // Timeout starting the service. + return Ok(super::status::StatusResult::Stopped); + } + return Err(super::ServiceManagerError::WindowsError( + "failed to start service".to_string(), + )); + } +} + +fn windows_to_manager_err(err: windows_service::Error) -> super::ServiceManagerError { + if let windows_service::Error::Winapi(_) = err { + // Winapi does not contain the full error. Get the actual error from windows. + return super::ServiceManagerError::WindowsError( + windows::core::Error::from_win32().to_string(), // Internally will call `GetLastError()` and parse the result. + ); + } else { + return super::ServiceManagerError::WindowsError(err.to_string()); + } +} diff --git a/desktop/tauri/src-tauri/src/traymenu.rs b/desktop/tauri/src-tauri/src/traymenu.rs new file mode 100644 index 00000000..6456797f --- /dev/null +++ b/desktop/tauri/src-tauri/src/traymenu.rs @@ -0,0 +1,344 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use log::{debug, error}; +use tauri::{ + menu::{ + CheckMenuItem, CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem, + SubmenuBuilder, + }, + tray::{ClickType, TrayIcon, TrayIconBuilder}, + Icon, Manager, Wry, +}; +use tauri_plugin_dialog::DialogExt; + +use crate::{ + portapi::{ + client::PortAPI, + message::ParseError, + models::{ + config::BooleanValue, + spn::SPNStatus, + subsystem::{self, Subsystem}, + }, + types::{Request, Response}, + }, + portmaster::PortmasterExt, + window::{create_main_window, may_navigate_to_ui, open_window}, +}; + +pub type AppIcon = TrayIcon; + +lazy_static! { + // Set once setup_tray_menu executed. + static ref SPN_BUTTON: Mutex>> = Mutex::new(None); +} + +// Icons +// +const BLUE_ICON: &'static [u8] = + include_bytes!("../../assets/icons/pm_light_blue_512.ico"); +const RED_ICON: &'static [u8] = + include_bytes!("../../assets/icons/pm_light_red_512.ico"); +const YELLOW_ICON: &'static [u8] = + include_bytes!("../../assets/icons/pm_light_yellow_512.ico"); +const GREEN_ICON: &'static [u8] = + include_bytes!("../../assets/icons/pm_light_green_512.ico"); + +pub fn setup_tray_menu( + app: &mut tauri::App, +) -> core::result::Result> { + // Tray menu + let close_btn = MenuItemBuilder::with_id("close", "Exit").build(app); + let open_btn = MenuItemBuilder::with_id("open", "Open").build(app); + + let spn = CheckMenuItemBuilder::with_id("spn", "Use SPN").build(app); + + // Store the SPN button reference + let mut button_ref = SPN_BUTTON.lock().unwrap(); + *button_ref = Some(spn.clone()); + + let force_show_window = MenuItemBuilder::with_id("force-show", "Force Show UI").build(app); + let reload_btn = MenuItemBuilder::with_id("reload", "Reload User Interface").build(app); + let developer_menu = SubmenuBuilder::new(app, "Developer") + .items(&[&reload_btn, &force_show_window]) + .build()?; + + // Drop the reference now so we unlock immediately. + drop(button_ref); + + let menu = MenuBuilder::new(app) + .items(&[ + &spn, + &PredefinedMenuItem::separator(app), + &open_btn, + &close_btn, + &developer_menu, + ]) + .build()?; + + let icon = TrayIconBuilder::new() + .icon(Icon::Raw(RED_ICON.to_vec())) + .menu(&menu) + .on_menu_event(move |app, event| match event.id().as_ref() { + "close" => { + let handle = app.clone(); + app.dialog() + .message("This does not stop the Portmaster system service") + .title("Do you really want to quit the user interface?") + .ok_button_label("Yes, exit") + .cancel_button_label("No") + .show(move |answer| { + if answer { + let _ = handle.emit("exit-requested", ""); + handle.exit(0); + } + }); + } + "open" => { + let _ = open_window(app); + } + "reload" => { + if let Ok(mut win) = open_window(app) { + may_navigate_to_ui(&mut win, true); + } + } + "force-show" => { + match create_main_window(app) { + Ok(mut win) => { + may_navigate_to_ui(&mut win, true); + if let Err(err) = win.show() { + error!("[tauri] failed to show window: {}", err.to_string()); + }; + } + Err(err) => { + error!("[tauri] failed to create main window: {}", err.to_string()); + } + }; + } + "spn" => { + let btn = SPN_BUTTON.lock().unwrap(); + + if let Some(bt) = &*btn { + if let Ok(is_checked) = bt.is_checked() { + app.portmaster().set_spn_enabled(is_checked); + } + } + } + other => { + error!("unknown menu event id: {}", other); + } + }) + .on_tray_icon_event(|tray, event| { + // not supported on linux + if event.click_type == ClickType::Left { + let _ = open_window(tray.app_handle()); + } + }) + .build(app)?; + + Ok(icon) +} + +pub fn update_icon(icon: AppIcon, subsystems: HashMap, spn_status: String) { + // iterate over the subsytems and check if there's a module failure + let failure = subsystems + .values() + .into_iter() + .map(|s| s.failure_status) + .fold( + subsystem::FAILURE_NONE, + |acc, s| { + if s > acc { + s + } else { + acc + } + }, + ); + + let next_icon = match failure { + subsystem::FAILURE_WARNING => YELLOW_ICON, + subsystem::FAILURE_ERROR => RED_ICON, + _ => match spn_status.as_str() { + "connected" | "connecting" => BLUE_ICON, + _ => GREEN_ICON, + }, + }; + + _ = icon.set_icon(Some(Icon::Raw(next_icon.to_vec()))); +} + +pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) { + let icon = match app.tray() { + Some(icon) => icon, + None => { + error!("cancel try_handler: missing try icon"); + return; + } + }; + + let mut subsystem_subscription = match cli + .request(Request::QuerySubscribe( + "query runtime:subsystems/".to_string(), + )) + .await + { + Ok(rx) => rx, + Err(err) => { + error!( + "cancel try_handler: failed to subscribe to 'runtime:subsystems': {}", + err + ); + return; + } + }; + + let mut spn_status_subscription = match cli + .request(Request::QuerySubscribe( + "query runtime:spn/status".to_string(), + )) + .await + { + Ok(rx) => rx, + Err(err) => { + error!( + "cancel try_handler: failed to subscribe to 'runtime:spn/status': {}", + err + ); + return; + } + }; + + let mut spn_config_subscription = match cli + .request(Request::QuerySubscribe( + "query config:spn/enable".to_string(), + )) + .await + { + Ok(rx) => rx, + Err(err) => { + error!( + "cancel try_handler: failed to subscribe to 'runtime:spn/enable': {}", + err + ); + return; + } + }; + + _ = icon.set_icon(Some(Icon::Raw(BLUE_ICON.to_vec()))); + + let mut subsystems: HashMap = HashMap::new(); + let mut spn_status: String = "".to_string(); + + loop { + tokio::select! { + msg = subsystem_subscription.recv() => { + let msg = match msg { + Some(m) => m, + None => { break } + }; + + let res = match msg { + Response::Ok(key, payload) => Some((key, payload)), + Response::New(key, payload) => Some((key, payload)), + Response::Update(key, payload) => Some((key, payload)), + _ => None, + }; + + if let Some((_, payload)) = res { + match payload.parse::() { + Ok(n) => { + subsystems.insert(n.id.clone(), n); + + update_icon(icon.clone(), subsystems.clone(), spn_status.clone()); + }, + Err(err) => match err { + ParseError::JSON(err) => { + error!("failed to parse subsystem: {}", err); + } + _ => { + error!("unknown error when parsing notifications payload"); + } + }, + } + } + }, + msg = spn_status_subscription.recv() => { + let msg = match msg { + Some(m) => m, + None => { break } + }; + + let res = match msg { + Response::Ok(key, payload) => Some((key, payload)), + Response::New(key, payload) => Some((key, payload)), + Response::Update(key, payload) => Some((key, payload)), + _ => None, + }; + + if let Some((_, payload)) = res { + match payload.parse::() { + Ok(value) => { + debug!("SPN status update: {}", value.status); + spn_status = value.status.clone(); + + update_icon(icon.clone(), subsystems.clone(), spn_status.clone()); + }, + Err(err) => match err { + ParseError::JSON(err) => { + error!("failed to parse spn status value: {}", err) + }, + _ => { + error!("unknown error when parsing spn status value") + } + } + } + } + }, + msg = spn_config_subscription.recv() => { + let msg = match msg { + Some(m) => m, + None => { break } + }; + + let res = match msg { + Response::Ok(key, payload) => Some((key, payload)), + Response::New(key, payload) => Some((key, payload)), + Response::Update(key, payload) => Some((key, payload)), + _ => None, + }; + + if let Some((_, payload)) = res { + match payload.parse::() { + Ok(value) => { + let mut btn = SPN_BUTTON.lock().unwrap(); + + if let Some(btn) = &mut *btn { + if let Some(value) = value.value { + _ = btn.set_checked(value); + } else { + _ = btn.set_checked(false); + } + } + }, + Err(err) => match err { + ParseError::JSON(err) => { + error!("failed to parse config value: {}", err) + }, + _ => { + error!("unknown error when parsing config value") + } + } + } + } + } + } + } + + if let Some(btn) = &mut *(SPN_BUTTON.lock().unwrap()) { + _ = btn.set_checked(false); + } + + _ = icon.set_icon(Some(Icon::Raw(RED_ICON.to_vec()))); +} diff --git a/desktop/tauri/src-tauri/src/window.rs b/desktop/tauri/src-tauri/src/window.rs new file mode 100644 index 00000000..af3bce97 --- /dev/null +++ b/desktop/tauri/src-tauri/src/window.rs @@ -0,0 +1,153 @@ +use log::{debug, error}; +use tauri::{AppHandle, Manager, Result, UserAttentionType, Window, WindowBuilder, WindowUrl}; + +use crate::portmaster::PortmasterExt; + +/// Either returns the existing "main" window or creates a new one. +/// +/// The window is not automatically shown (i.e it starts hidden). +/// If a new main window is created (i.e. the tauri app was minimized to system-tray) +/// then the window will be automatically navigated to the Portmaster UI endpoint +/// if ::websocket::is_portapi_reachable returns true. +/// +/// Either the existing or the newly created window is returned. +pub fn create_main_window(app: &AppHandle) -> Result { + let mut window = if let Some(window) = app.get_window("main") { + debug!("[tauri] main window already created"); + + window + } else { + debug!("[tauri] creating main window"); + + let res = WindowBuilder::new(app, "main", WindowUrl::App("index.html".into())) + .visible(false) + .build(); + + match res { + Ok(win) => { + win.once("tauri://error", |event| { + error!("failed to open tauri window: {}", event.payload()); + }); + + win + } + Err(err) => { + error!("[tauri] failed to create main window: {}", err.to_string()); + + return Err(err); + } + } + }; + + // If the window is not yet navigated to the Portmaster UI, do it now. + may_navigate_to_ui(&mut window, false); + + #[cfg(debug_assertions)] + if let Ok(_) = std::env::var("TAURI_SHOW_IMMEDIATELY") { + debug!("[tauri] TAURI_SHOW_IMMEDIATELY is set, opening window"); + + if let Err(err) = window.show() { + error!("[tauri] failed to show window: {}", err.to_string()); + } + } + + Ok(window) +} + +pub fn create_splash_window(app: &AppHandle) -> Result { + if let Some(window) = app.get_window("splash") { + let _ = window.show(); + Ok(window) + } else { + let window = WindowBuilder::new(app, "splash", WindowUrl::App("index.html".into())) + .center() + .closable(false) + .focused(true) + .resizable(false) + .visible(true) + .title("Portmaster") + .inner_size(600.0, 250.0) + .build()?; + + let _ = window.request_user_attention(Some(UserAttentionType::Informational)); + + Ok(window) + } +} + +pub fn close_splash_window(app: &AppHandle) -> Result<()> { + if let Some(window) = app.get_window("splash") { + return window.close(); + } + return Err(tauri::Error::WindowNotFound); +} + +/// Opens a window for the tauri application. +/// +/// If the main window has already been created, it is instructed to +/// show even if we're currently not connected to Portmaster. +/// This is safe since the main-window will only be created if Portmaster API +/// was reachable so the angular application must have finished bootstrapping. +/// +/// If there's not main window and the Portmaster API is reachable we create a new +/// main window. +/// +/// If the Portmaster API is unreachable and there's no main window yet, we show the +/// splash-screen window. +pub fn open_window(app: &AppHandle) -> Result { + if app.portmaster().is_reachable() { + match app.get_window("main") { + Some(win) => { + app.portmaster().show_window(); + + Ok(win) + } + None => { + app.portmaster().show_window(); + + create_main_window(app) + } + } + } else { + debug!("Show splash screen"); + create_splash_window(app) + } +} + +/// If the Portmaster Websocket database API is reachable the window will be navigated +/// to the HTTP endpoint of Portmaster to load the UI from there. +/// +/// Note that only happens if the window URL does not already point to the PM API. +/// +/// In #[cfg(debug_assertions)] the TAURI_PM_URL environment variable will be used +/// if set. +/// Otherwise or in release builds, it will be navigated to http://127.0.0.1:817. +pub fn may_navigate_to_ui(win: &mut Window, force: bool) { + if !win.app_handle().portmaster().is_reachable() && !force { + error!("[tauri] portmaster API is not reachable, not navigating"); + + return; + } + + if force || cfg!(debug_assertions) || win.url().as_str() == "tauri://localhost" { + #[cfg(debug_assertions)] + if let Ok(target_url) = std::env::var("TAURI_PM_URL") { + debug!("[tauri] navigating to {}", target_url); + + win.navigate(target_url.parse().unwrap()); + + return; + } + + #[cfg(debug_assertions)] + { + debug!("[tauri] navigating to http://localhost:4200"); + win.navigate("http://localhost:4200".parse().unwrap()); + } + + #[cfg(not(debug_assertions))] + win.navigate("http://localhost:817".parse().unwrap()); + } else { + error!("not navigating to user interface: current url: {}", win.url().as_str()); + } +} diff --git a/desktop/tauri/src-tauri/src/xdg/mod.rs b/desktop/tauri/src-tauri/src/xdg/mod.rs new file mode 100644 index 00000000..607f5d0e --- /dev/null +++ b/desktop/tauri/src-tauri/src/xdg/mod.rs @@ -0,0 +1,585 @@ +use cached::proc_macro::once; +use dataurl::DataUrl; +use gdk_pixbuf::{Pixbuf, PixbufError}; +use gtk_sys::{ + gtk_icon_info_free, gtk_icon_info_get_filename, gtk_icon_theme_get_default, + gtk_icon_theme_lookup_icon, GtkIconTheme, +}; +use log::{debug, error}; +use std::collections::HashMap; +use std::ffi::{c_char, c_int}; +use std::ffi::{CStr, CString}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::{ + env, fs, + io::{Error, ErrorKind}, +}; +use thiserror::Error; + +use dirs; +use ini::{Ini, ParseOption}; + +static mut GTK_DEFAULT_THEME: Option<*mut GtkIconTheme> = None; + +lazy_static! { + static ref APP_INFO_CACHE: Arc>>> = + Arc::new(RwLock::new(HashMap::new())); +} + +#[derive(Debug, Error)] +pub enum LookupError { + #[error(transparent)] + IoError(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +#[derive(Clone, serde::Serialize)] +pub struct AppInfo { + pub icon_name: String, + pub app_name: String, + pub icon_dataurl: String, + pub comment: String, +} + +impl Default for AppInfo { + fn default() -> Self { + AppInfo { + icon_dataurl: "".to_string(), + icon_name: "".to_string(), + app_name: "".to_string(), + comment: "".to_string(), + } + } +} + +#[derive(Clone, serde::Serialize, Debug)] +pub struct ProcessInfo { + pub exec_path: String, + pub cmdline: String, + pub pid: i64, + pub matching_path: String, +} + +impl std::fmt::Display for ProcessInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} (cmdline={}) (pid={}) (matching_path={})", + self.exec_path, self.cmdline, self.pid, self.matching_path + ) + } +} + +pub fn get_app_info(process_info: ProcessInfo) -> Result { + { + let cache = APP_INFO_CACHE.read().unwrap(); + + if let Some(value) = cache.get(process_info.exec_path.as_str()) { + match value { + Some(app_info) => return Ok(app_info.clone()), + None => { + return Err(LookupError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "not found", + ))) + } + } + } + } + + let mut needles = Vec::new(); + if !process_info.exec_path.is_empty() { + needles.push(process_info.exec_path.as_str()) + } + if !process_info.cmdline.is_empty() { + needles.push(process_info.cmdline.as_str()) + } + if !process_info.matching_path.is_empty() { + needles.push(process_info.matching_path.as_str()) + } + + // sort and deduplicate + needles.sort(); + needles.dedup(); + + debug!("Searching app info for {:?}", process_info); + + let mut desktop_files = Vec::new(); + for dir in get_application_directories()? { + let mut files = find_desktop_files(dir.as_path())?; + desktop_files.append(&mut files); + } + + let mut matches = Vec::new(); + for needle in needles.clone() { + debug!("Trying needle {} on exec path", needle); + + match try_get_app_info(needle, CheckType::Exec, &desktop_files) { + Ok(mut result) => { + matches.append(&mut result); + } + Err(LookupError::IoError(ioerr)) => { + if ioerr.kind() != ErrorKind::NotFound { + return Err(ioerr.into()); + } + } + }; + + match try_get_app_info(needle, CheckType::Name, &desktop_files) { + Ok(mut result) => { + matches.append(&mut result); + } + Err(LookupError::IoError(ioerr)) => { + if ioerr.kind() != ErrorKind::NotFound { + return Err(ioerr.into()); + } + } + }; + } + + if matches.is_empty() { + APP_INFO_CACHE + .write() + .unwrap() + .insert(process_info.exec_path, None); + + Err(Error::new(ErrorKind::NotFound, format!("failed to find app info")).into()) + } else { + // sort matches by length + matches.sort_by(|a, b| a.1.cmp(&b.1)); + + for mut info in matches { + match get_icon_as_png_dataurl(&info.0.icon_name, 32) { + Ok(du) => { + debug!( + "[xdg] best match for {:?} is {:?} with len {}", + process_info, info.0.icon_name, info.1 + ); + + info.0.icon_dataurl = du.1; + + APP_INFO_CACHE + .write() + .unwrap() + .insert(process_info.exec_path, Some(info.0.clone())); + + return Ok(info.0); + } + Err(err) => { + error!( + "{}: failed to get icon: {}", + info.0.icon_name, + err.to_string() + ); + } + }; + } + + Err(Error::new(ErrorKind::NotFound, format!("failed to find app info")).into()) + } +} + +/// Returns a vector of application directories that are expected +/// to contain all .desktop files the current user has access to. +/// The result of this function is cached for 5 minutes as it's not expected +/// that application directories actually change. +#[once(time = 300, sync_writes = true, result = true)] +fn get_application_directories() -> Result> { + let xdg_home = match env::var_os("XDG_DATA_HOME") { + Some(path) => PathBuf::from(path), + None => { + let home = dirs::home_dir() + .ok_or(Error::new(ErrorKind::Other, "Failed to get home directory"))?; + + home.join(".local/share") + } + }; + + let extra_application_dirs = match env::var_os("XDG_DATA_DIRS") { + Some(paths) => env::split_paths(&paths).map(PathBuf::from).collect(), + None => { + // Fallback if XDG_DATA_DIRS is not set. If it's set, it normally already contains /usr/share and + // /usr/local/share + vec![ + PathBuf::from("/usr/share"), + PathBuf::from("/usr/local/share"), + ] + } + }; + + let mut app_dirs = Vec::new(); + for extra_dir in extra_application_dirs { + app_dirs.push(extra_dir.join("applications")); + } + + app_dirs.push(xdg_home.join("applications")); + + Ok(app_dirs) +} + +// TODO(ppacher): cache the result of find_desktop_files as well. +// Though, seems like we cannot use the #[cached::proc_macro::cached] or #[cached::proc_macro::once] macros here +// because [`Result>>`] does not implement [`Clone`] +fn find_desktop_files(path: &Path) -> Result> { + match path.read_dir() { + Ok(files) => { + let desktop_files = files + .filter_map(|entry| entry.ok()) + .filter(|entry| match entry.file_type() { + Ok(ft) => ft.is_file() || ft.is_symlink(), + _ => false, + }) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".desktop")) + .collect::>(); + + Ok(desktop_files) + } + Err(err) => { + // We ignore NotFound errors here because not all application + // directories need to exist. + if err.kind() == ErrorKind::NotFound { + Ok(Vec::new()) + } else { + Err(err.into()) + } + } + } +} + +enum CheckType { + Name, + Exec, +} + +fn try_get_app_info( + needle: &str, + check: CheckType, + desktop_files: &Vec, +) -> Result> { + let path = PathBuf::from(needle); + + let file_name = path.as_path().file_name().unwrap_or_default().to_str(); + + let mut result = Vec::new(); + + for file in desktop_files { + let content = Ini::load_from_file_opt( + file.path(), + ParseOption { + enabled_escape: false, + enabled_quote: true, + }, + ) + .map_err(|err| Error::new(ErrorKind::Other, err.to_string()))?; + + let desktop_section = match content.section(Some("Desktop Entry")) { + Some(section) => section, + None => { + continue; + } + }; + + let matches = match check { + CheckType::Name => { + let name = match desktop_section.get("Name") { + Some(name) => name, + None => { + continue; + } + }; + + if let Some(file_name) = file_name { + if name.to_lowercase().contains(file_name) { + file_name.len() + } else { + 0 + } + } else { + 0 + } + } + CheckType::Exec => { + let exec = match desktop_section.get("Exec") { + Some(exec) => exec, + None => { + continue; + } + }; + + if exec.to_lowercase().contains(needle) { + needle.len() + } else if let Some(file_name) = file_name { + if exec.to_lowercase().starts_with(file_name) { + file_name.len() + } else { + 0 + } + } else { + 0 + } + } + }; + + if matches > 0 { + debug!( + "[xdg] found matching desktop for needle {} file at {}", + needle, + file.path().to_string_lossy() + ); + + let info = parse_app_info(desktop_section); + + result.push((info, matches)); + } + } + + if result.len() > 0 { + Ok(result) + } else { + Err(Error::new(ErrorKind::NotFound, "no matching .desktop files found").into()) + } +} + +fn parse_app_info(props: &ini::Properties) -> AppInfo { + AppInfo { + icon_dataurl: "".to_string(), + app_name: props.get("Name").unwrap_or_default().to_string(), + comment: props.get("Comment").unwrap_or_default().to_string(), + icon_name: props.get("Icon").unwrap_or_default().to_string(), + } +} + +fn get_icon_as_png_dataurl(name: &str, size: i8) -> Result<(String, String)> { + unsafe { + if GTK_DEFAULT_THEME.is_none() { + let theme = gtk_icon_theme_get_default(); + if theme.is_null() { + debug!("You have to initialize GTK!"); + return Err(Error::new(ErrorKind::Other, "You have to initialize GTK!").into()); + } + + let theme = gtk_icon_theme_get_default(); + GTK_DEFAULT_THEME = Some(theme); + } + } + + let mut icons = Vec::new(); + + // push the name + icons.push(name); + + // if we don't find the icon by it's name and it includes an extension, + // drop the extension and try without. + let name_without_ext; + if let Some(ext) = PathBuf::from(name).extension() { + let ext = ext.to_str().unwrap(); + + let mut ext_dot = String::from(".").to_owned(); + ext_dot.push_str(ext); + + name_without_ext = name.replace(ext_dot.as_str(), ""); + icons.push(name_without_ext.as_str()); + } else { + name_without_ext = String::from(name); + } + + // The xdg-desktop icon specification allows a fallback for icons that contains dashes. + // i.e. the following lookup order is used: + // - network-wired-secure + // - network-wired + // - network + // + name_without_ext + .split("-") + .for_each(|part| icons.push(part)); + + for name in icons { + debug!("trying to load icon {}", name); + + unsafe { + let c_str = CString::new(name).unwrap(); + + let icon_info = gtk_icon_theme_lookup_icon( + GTK_DEFAULT_THEME.unwrap(), + c_str.as_ptr() as *const c_char, + size as c_int, + 0, + ); + if icon_info.is_null() { + error!("failed to lookup icon {}", name); + + continue; + } + + let filename = gtk_icon_info_get_filename(icon_info); + + let filename = CStr::from_ptr(filename).to_str().unwrap().to_string(); + + gtk_icon_info_free(icon_info); + + match read_and_convert_pixbuf(filename.clone()) { + Ok(pb) => return Ok((filename, pb)), + Err(err) => { + error!("failed to load icon from {}: {}", filename, err.to_string()); + + continue; + } + } + } + } + + Err(Error::new(ErrorKind::NotFound, "failed to find icon").into()) +} + +/* +fn get_icon_as_file_2(ext: &str, size: i32) -> io::Result<(String, Vec)> { + let result: String; + let buf: Vec; + + unsafe { + let filename = CString::new(ext).unwrap(); + let null: u8 = 0; + let p_null = &null as *const u8; + let nullsize: usize = 0; + let mut res = 0; + let p_res = &mut res as *mut i32; + let p_res = gio_sys::g_content_type_guess(filename.as_ptr(), p_null, nullsize, p_res); + let icon = gio_sys::g_content_type_get_icon(p_res); + g_free(p_res as *mut c_void); + if DEFAULT_THEME.is_none() { + let theme = gtk_icon_theme_get_default(); + if theme.is_null() { + println!("You have to initialize GTK!"); + return Err(io::Error::new(io::ErrorKind::Other, "You have to initialize GTK!")) + } + let theme = gtk_icon_theme_get_default(); + DEFAULT_THEME = Some(theme); + } + let icon_names = gio_sys::g_themed_icon_get_names(icon as *mut GThemedIcon) as *mut *const i8; + let icon_info = gtk_icon_theme_choose_icon(DEFAULT_THEME.unwrap(), icon_names, size, GTK_ICON_LOOKUP_NO_SVG); + let filename = gtk_icon_info_get_filename(icon_info); + + gtk_icon_info_free(icon_info); + + result = CStr::from_ptr(filename).to_str().unwrap().to_string(); + + buf = match read_and_convert_pixbuf(result.clone()) { + Ok(pb) => pb, + Err(_) => Vec::new(), + }; + + g_object_unref(icon as *mut GObject); + } + + Ok((result, buf)) + +} +*/ + +fn read_and_convert_pixbuf(result: String) -> std::result::Result { + let pixbuf = match Pixbuf::from_file(result.clone()) { + Ok(data) => Ok(data), + Err(err) => { + error!("failed to load icon pixbuf: {}", err.to_string()); + + Pixbuf::from_resource(result.clone().as_str()) + } + }; + + match pixbuf { + Ok(data) => match data.save_to_bufferv("png", &[]) { + Ok(data) => { + let mut du = DataUrl::new(); + + du.set_media_type(Some("image/png".to_string())); + du.set_data(&data); + + Ok(du.to_string()) + } + Err(err) => { + return Err(glib::Error::new( + PixbufError::Failed, + err.to_string().as_str(), + )); + } + }, + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ctor::ctor; + use log::warn; + use which::which; + + // Use the ctor create to setup a global initializer before our tests are executed. + #[ctor] + fn init() { + // we need to initialize GTK before running our tests. + // This is only required when unit tests are executed as + // GTK will otherwise be initialize by Tauri. + + gtk::init().expect("failed to initialize GTK for tests") + } + + #[test] + fn test_find_info_success() { + // we expect at least one of the following binaries to be installed + // on a linux system + let test_binaries = vec![ + "vim", // vim is mostly bundled with a .desktop file + "blueman-manager", // blueman-manager is the default bluetooth manager on most DEs + "nautilus", // nautlis: file-manager on GNOME DE + "thunar", // thunar: file-manager on XFCE + "dolphin", // dolphin: file-manager on KDE + ]; + + let mut bin_found = false; + + for cmd in test_binaries { + match which(cmd) { + Ok(bin) => { + bin_found = true; + + let bin = bin.to_string_lossy().to_string(); + + let result = get_app_info(ProcessInfo { + cmdline: cmd.to_string(), + exec_path: bin.clone(), + matching_path: bin.clone(), + pid: 0, + }) + .expect( + format!( + "expected to find app info for {} ({})", + bin, + cmd.to_string() + ) + .as_str(), + ); + + let empty_string = String::from(""); + + // just make sure all fields are populated + assert_ne!(result.app_name, empty_string); + assert_ne!(result.comment, empty_string); + assert_ne!(result.icon_name, empty_string); + assert_ne!(result.icon_dataurl, empty_string); + } + Err(_) => { + // binary not found + continue; + } + } + } + + if !bin_found { + warn!("test_find_info_success: no test binary found, test was skipped") + } + } +} diff --git a/desktop/tauri/src-tauri/tauri.conf.json b/desktop/tauri/src-tauri/tauri.conf.json new file mode 100644 index 00000000..c92731be --- /dev/null +++ b/desktop/tauri/src-tauri/tauri.conf.json @@ -0,0 +1,116 @@ +{ + "build": { + "beforeDevCommand": { + "script": "npm run tauri-dev", + "cwd": "../../angular", + "wait": false + }, + "devPath": "http://localhost:4100", + "distDir": "../../angular/dist/tauri-builtin", + "withGlobalTauri": true + }, + "package": { + "productName": "Portmaster", + "version": "0.1.0" + }, + "plugins": { + "cli": { + "args": [ + { + "short": "d", + "name": "data", + "description": "Path to the installation directory", + "takesValue": true + }, + { + "short": "b", + "name": "background", + "description": "Start in the background without opening a window" + }, + { + "name": "with-notifications", + "description": "Enable experimental notifications via Tauri. Replaces the notifier app." + }, + { + "name": "with-prompts", + "description": "Enable experimental prompt support via Tauri. Replaces the notifier app." + } + ] + } + }, + "tauri": { + "bundle": { + "active": true, + "category": "Utility", + "copyright": "Safing Limited Inc", + "deb": { + "depends": [ + "libayatana-appindicator3" + ], + "desktopTemplate": "../../../packaging/linux/portmaster.desktop", + "files": { + "/usr/lib/systemd/system/portmaster.service": "../../../packaging/linux/portmaster.service", + "/etc/xdg/autostart/portmaster.desktop": "../../../packaging/linux/portmaster-autostart.desktop", + "/var/": "../../../packaging/linux/var", + "../control/postinst": "../../../packaging/linux/debian/postinst", + "../control/postrm": "../../../packaging/linux/debian/postrm" + } + }, + "externalBin": [ + "binaries/portmaster-start", + "binaries/portmaster-core" + ], + "icon": [ + "../assets/icons/pm_dark_512.png", + "../assets/icons/pm_dark_512.ico", + "../assets/icons/pm_light_512.png", + "../assets/icons/pm_light_512.ico" + ], + "identifier": "io.safing.portmaster", + "longDescription": "", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null + }, + "resources": [], + "shortDescription": "", + "targets": [ + "deb", + "appimage", + "nsis", + "msi", + "app" + ], + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "security": { + "csp": null, + "dangerousRemoteDomainIpcAccess": [ + { + "windows": [ + "main", + "prompt" + ], + "plugins": [ + "shell", + "os", + "clipboard-manager", + "event", + "window", + "cli", + "portmaster" + ], + "domain": "localhost" + } + ] + }, + "windows": [] + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 78158f63..2a52ff35 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,13 @@ toolchain go1.21.2 replace github.com/tc-hib/winres => github.com/dhaavi/winres v0.2.2 require ( + fyne.io/systray v1.10.0 github.com/Xuanwo/go-locale v1.1.0 github.com/agext/levenshtein v1.2.3 - github.com/cilium/ebpf v0.12.3 + github.com/awalterschulze/gographviz v2.0.3+incompatible + github.com/cilium/ebpf v0.14.0 github.com/coreos/go-iptables v0.7.0 + github.com/dhaavi/go-notify v0.0.0-20190209221809-c404b1f22435 github.com/florianl/go-conntrack v0.4.0 github.com/florianl/go-nfqueue v1.3.1 github.com/fogleman/gg v1.3.0 @@ -23,74 +26,76 @@ require ( github.com/hashicorp/go-version v1.6.0 github.com/jackc/puddle/v2 v2.2.1 github.com/mat/besticon v3.12.0+incompatible - github.com/miekg/dns v1.1.57 + github.com/miekg/dns v1.1.58 github.com/mitchellh/go-server-timing v1.0.1 + github.com/mr-tron/base58 v1.2.0 github.com/oschwald/maxminddb-golang v1.12.0 + github.com/r3labs/diff/v3 v3.0.1 + github.com/rot256/pblind v0.0.0-20231024115251-cd3f239f28c1 github.com/safing/jess v0.3.3 - github.com/safing/portbase v0.18.9 + github.com/safing/portbase v0.19.4 github.com/safing/portmaster-android/go v0.0.0-20230830120134-3226ceac3bec - github.com/safing/spn v0.7.5 github.com/shirou/gopsutil v3.21.11+incompatible github.com/spf13/cobra v1.8.0 github.com/spkg/zipfs v0.7.1 github.com/stretchr/testify v1.8.4 github.com/tannerryan/ring v1.1.2 - github.com/tc-hib/winres v0.2.1 + github.com/tc-hib/winres v0.3.1 github.com/tevino/abool v1.2.0 github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26 github.com/vincent-petithory/dataurl v1.0.0 - github.com/vlabo/portmaster_windows_rust_kext/kext_interface v0.0.0-20240120091731-1a3450b13959 - golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e - golang.org/x/net v0.20.0 - golang.org/x/sync v0.6.0 - golang.org/x/sys v0.16.0 + golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 + golang.org/x/image v0.15.0 + golang.org/x/net v0.24.0 + golang.org/x/sync v0.7.0 + golang.org/x/sys v0.19.0 gopkg.in/yaml.v3 v3.0.1 - zombiezen.com/go/sqlite v1.0.0 + zombiezen.com/go/sqlite v1.2.0 ) require ( - github.com/VictoriaMetrics/metrics v1.29.1 // indirect + github.com/VictoriaMetrics/metrics v1.33.1 // indirect github.com/aead/ecdh v0.2.0 // indirect github.com/aead/serpent v0.0.0-20160714141033-fba169763ea6 // indirect github.com/alessio/shellescape v1.4.2 // indirect github.com/armon/go-radix v1.0.0 // indirect - github.com/awalterschulze/gographviz v2.0.3+incompatible // indirect github.com/bluele/gcache v0.0.2 // indirect + github.com/brianvoe/gofakeit v3.18.0+incompatible // indirect github.com/danieljoos/wincred v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor v1.5.1 // indirect - github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/fxamacker/cbor/v2 v2.6.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus v4.1.0+incompatible // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f // indirect github.com/google/btree v1.1.2 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/netlink v1.7.2 // indirect - github.com/mdlayher/socket v0.5.0 // indirect + github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rot256/pblind v0.0.0-20231024115251-cd3f239f28c1 // indirect github.com/satori/go.uuid v1.2.0 // indirect github.com/seehuhn/fortuna v1.0.1 // indirect github.com/seehuhn/sha256d v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -101,20 +106,19 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/yusufpapurcu/wmi v1.2.3 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zalando/go-keyring v0.2.3 // indirect github.com/zeebo/blake3 v0.2.3 // indirect - go.etcd.io/bbolt v1.3.8 // indirect - golang.org/x/crypto v0.18.0 // indirect - golang.org/x/image v0.15.0 // indirect - golang.org/x/mod v0.14.0 // indirect + go.etcd.io/bbolt v1.3.9 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/mod v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.16.1 // indirect + golang.org/x/tools v0.20.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gvisor.dev/gvisor v0.0.0-20240110202538-8053cd8f0bf6 // indirect - modernc.org/libc v1.40.1 // indirect + gvisor.dev/gvisor v0.0.0-20240409213450-87d8df37c71e // indirect + modernc.org/libc v1.49.3 // indirect modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.7.2 // indirect - modernc.org/sqlite v1.28.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.29.6 // indirect ) diff --git a/go.sum b/go.sum index 50e19078..ebeca87f 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,14 @@ cloud.google.com/go v0.16.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +fyne.io/systray v1.10.0 h1:Yr1D9Lxeiw3+vSuZWPlaHC8BMjIHZXJKkek706AfYQk= +fyne.io/systray v1.10.0/go.mod h1:oM2AQqGJ1AMo4nNqZFYU8xYygSBZkW2hmdJ7n4yjedE= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/VictoriaMetrics/metrics v1.29.1 h1:yTORfGeO1T0C6P/tEeT4Mf7rBU5TUu3kjmHvmlaoeO8= github.com/VictoriaMetrics/metrics v1.29.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.33.1 h1:CNV3tfm2Kpv7Y9W3ohmvqgFWPR55tV2c7M2U6OIo+UM= +github.com/VictoriaMetrics/metrics v1.33.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg= github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs= github.com/aead/ecdh v0.2.0 h1:pYop54xVaq/CEREFEcukHRZfTdjiWvYIsZDXXrBapQQ= @@ -30,6 +34,8 @@ github.com/cilium/ebpf v0.5.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4= github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM= +github.com/cilium/ebpf v0.14.0 h1:0PsxAjO6EjI1rcT+rkp6WcCnE0ZvfkXBYiMedJtrSUs= +github.com/cilium/ebpf v0.14.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8= github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -42,6 +48,8 @@ github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dhaavi/go-notify v0.0.0-20190209221809-c404b1f22435 h1:AnwbdEI8eV3GzLM3SlrJlYmYa6OB5X8RwY4A8QJOCP0= +github.com/dhaavi/go-notify v0.0.0-20190209221809-c404b1f22435/go.mod h1:EMJ8XWTopp8OLRBMUm9vHE8Wn48CNpU21HM817OKNrc= github.com/dhaavi/winres v0.2.2 h1:SUago7FwhgLSMyDdeuV6enBZ+ZQSl0KwcnbWzvlfBls= github.com/dhaavi/winres v0.2.2/go.mod h1:1NTs+/DtKP1BplIL1+XQSoq4X1PUfLczexS7gf3x9T4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -55,6 +63,7 @@ github.com/florianl/go-nfqueue v1.3.1 h1:khQ9fYCrjbu5CF8dZF55G2RTIEIQRI0Aj5k3msJ github.com/florianl/go-nfqueue v1.3.1/go.mod h1:aHWbgkhryJxF5XxYvJ3oRZpdD4JP74Zu/hP1zuhja+M= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/frankban/quicktest v1.11.3 h1:8sXhOn0uLys67V8EsXLc6eszDs8VXWxL3iRvebPhedY= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= @@ -63,6 +72,8 @@ github.com/fxamacker/cbor v1.5.1 h1:XjQWBgdmQyqimslUh5r4tUGmoqzHmBFQOImkWGi2awg= github.com/fxamacker/cbor v1.5.1/go.mod h1:3aPGItF174ni7dDzd6JZ206H8cmr4GDNBGpPa971zsU= github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= +github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/garyburd/redigo v1.1.1-0.20170914051019-70e1b1943d4f/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -70,6 +81,9 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= +github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= @@ -106,6 +120,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -145,6 +161,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -183,8 +201,12 @@ github.com/mdlayher/socket v0.1.0/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5A github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-server-timing v1.0.1 h1:f00/aIe8T3MrnLhQHu3tSWvnwc5GV/p5eutuu3hF/tE= @@ -194,6 +216,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= @@ -203,10 +227,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/r3labs/diff/v3 v3.0.1 h1:CBKqf3XmNRHXKmdU7mZP1w7TV0pDyVCis1AUHtA4Xtg= +github.com/r3labs/diff/v3 v3.0.1/go.mod h1:f1S9bourRbiM66NskseyUdo0fTmEE0qKrikYJX63dgo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rot256/pblind v0.0.0-20231024115251-cd3f239f28c1 h1:vfAp3Jbca7Vt8axzmkS5M/RtFJmj0CKmrtWAlHtesaA= github.com/rot256/pblind v0.0.0-20231024115251-cd3f239f28c1/go.mod h1:2x8fbm9T+uTl919COhEVHKGkve1DnkrEnDbtGptZuW8= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -214,6 +241,16 @@ github.com/safing/jess v0.3.3 h1:0U0bWdO0sFCgox+nMOqISFrnJpVmi+VFOW1xdX6q3qw= github.com/safing/jess v0.3.3/go.mod h1:t63qHB+4xd1HIv9MKN/qI2rc7ytvx7d6l4hbX7zxer0= github.com/safing/portbase v0.18.9 h1:j+ToHKQz0U2+Tx4jMP7QPky/H0R4uY6qUM+lIJlO6ks= github.com/safing/portbase v0.18.9/go.mod h1:Qrh3ck+7VZloFmnozCs9Hj8godhJAi55cmiDiC7BwTc= +github.com/safing/portbase v0.19.0 h1:2T6f/w90IdIsSgUfyXoveqZM7tVwW+IFrtLbPVXtY3k= +github.com/safing/portbase v0.19.0/go.mod h1:Qrh3ck+7VZloFmnozCs9Hj8godhJAi55cmiDiC7BwTc= +github.com/safing/portbase v0.19.1 h1:Uk/WyP9HsIJrCn0pE4a7AWIrfUSHyCOObQyRmXsGQ9A= +github.com/safing/portbase v0.19.1/go.mod h1:Qrh3ck+7VZloFmnozCs9Hj8godhJAi55cmiDiC7BwTc= +github.com/safing/portbase v0.19.2 h1:qGF5Jv9eEE33d2aIxeBQdnitnBoF44BGVFtboqfE+1A= +github.com/safing/portbase v0.19.2/go.mod h1:Qrh3ck+7VZloFmnozCs9Hj8godhJAi55cmiDiC7BwTc= +github.com/safing/portbase v0.19.3 h1:fzb4d2nzhmRq4Lt6sgn9R20iykireAkBNyf9pfGqQjk= +github.com/safing/portbase v0.19.3/go.mod h1:Qrh3ck+7VZloFmnozCs9Hj8godhJAi55cmiDiC7BwTc= +github.com/safing/portbase v0.19.4 h1:Oh7oUBp6xn5whhKtvnNKS5rhHqyXJDDxfxwf+gRswhQ= +github.com/safing/portbase v0.19.4/go.mod h1:Qrh3ck+7VZloFmnozCs9Hj8godhJAi55cmiDiC7BwTc= github.com/safing/portmaster-android/go v0.0.0-20230830120134-3226ceac3bec h1:oSJY1seobofPwpMoJRkCgXnTwfiQWNfGMCPDfqgAEfg= github.com/safing/portmaster-android/go v0.0.0-20230830120134-3226ceac3bec/go.mod h1:abwyAQrZGemWbSh/aCD9nnkp0SvFFf/mGWkAbOwPnFE= github.com/safing/spn v0.7.5 h1:WfkMs2omLrwxBWccGGG9Akx0AvsvJLG+W7rjWQpQhl4= @@ -246,6 +283,7 @@ github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -256,6 +294,8 @@ github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K0 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -277,6 +317,7 @@ github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8A github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/vlabo/portmaster_windows_rust_kext/kext_interface v0.0.0-20240120091731-1a3450b13959 h1:5j8cHx9n4drternoY4HXomea+4aYJuKMgnA3VhlG5WM= github.com/vlabo/portmaster_windows_rust_kext/kext_interface v0.0.0-20240120091731-1a3450b13959/go.mod h1:PCv02zl4R2SbmEUDetMKO+kTfvMvsVVZuOzOXRMcHwE= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -287,6 +328,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= @@ -297,14 +340,22 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= +go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e h1:723BNChdd0c2Wk6WOE320qGBiPtYx0F0Bbm1kriShfE= golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= +golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -314,6 +365,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -337,6 +390,10 @@ golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -344,6 +401,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -352,6 +411,7 @@ golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201118182958-a01c418693c7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -379,6 +439,10 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -399,6 +463,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -420,15 +486,27 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20240110202538-8053cd8f0bf6 h1:Ass5FAjCCQ5WECPE9NN7ItZnKJ38i6sM8MCMNBGee5I= gvisor.dev/gvisor v0.0.0-20240110202538-8053cd8f0bf6/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk= +gvisor.dev/gvisor v0.0.0-20240327015314-08ed01b28587 h1:wH3g/qTCPlVBwkFktYuKNFJGeo7ctLNEjzrMlfPrVgE= +gvisor.dev/gvisor v0.0.0-20240327015314-08ed01b28587/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0= +gvisor.dev/gvisor v0.0.0-20240409213450-87d8df37c71e h1:jpvBdtqDLzu2MZuruscr008NwJxiDidjFF4ZQq7YZbk= +gvisor.dev/gvisor v0.0.0-20240409213450-87d8df37c71e/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0= honnef.co/go/tools v0.2.1/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY= honnef.co/go/tools v0.2.2/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY= modernc.org/libc v1.40.1 h1:ZhRylEBcj3GyQbPVC8JxIg7SdrT4JOxIDJoUon0NfF8= modernc.org/libc v1.40.1/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= zombiezen.com/go/sqlite v1.0.0 h1:D2EvOZqumJBy+6t+0uNTTXnepUpB/pKG45op/UziI1o= zombiezen.com/go/sqlite v1.0.0/go.mod h1:Yx7FJ77tr7Ucwi5solhXAxpflyxk/BHNXArZ/JvDm60= +zombiezen.com/go/sqlite v1.2.0 h1:jja0Ubpzpl6bjr/bSaPyvafHO+extoDJJXIaqXT7VOU= +zombiezen.com/go/sqlite v1.2.0/go.mod h1:yRl27//s/9aXU3RWs8uFQwjkTG9gYNGEls6+6SvrclY= diff --git a/pack b/pack deleted file mode 100755 index 493b77f3..00000000 --- a/pack +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash - -baseDir="$( cd "$(dirname "$0")" && pwd )" -cd "$baseDir" - -COL_OFF="\033[0m" -COL_BOLD="\033[01;01m" -COL_RED="\033[31m" -COL_GREEN="\033[32m" -COL_YELLOW="\033[33m" - -function safe_execute { - echo -e "\n[....] $*" - $* - if [[ $? -eq 0 ]]; then - echo -e "[${COL_GREEN} OK ${COL_OFF}] $*" - else - echo -e "[${COL_RED}FAIL${COL_OFF}] $*" >/dev/stderr - echo -e "[${COL_RED}CRIT${COL_OFF}] ABORTING..." >/dev/stderr - exit 1 - fi -} - -function check { - ./cmds/portmaster-core/pack check - ./cmds/portmaster-start/pack check -} - -function build { - safe_execute ./cmds/portmaster-core/pack build - safe_execute ./cmds/portmaster-start/pack build -} - -function reset { - ./cmds/portmaster-core/pack reset - ./cmds/portmaster-start/pack reset -} - -case $1 in - "check" ) - check - ;; - "build" ) - build - ;; - "reset" ) - reset - ;; - * ) - echo "" - echo "build list:" - echo "" - check - echo "" - read -p "press [Enter] to start building" x - echo "" - build - echo "" - echo "finished building." - echo "" - ;; -esac diff --git a/packaging/linux/debian/postinst b/packaging/linux/debian/postinst new file mode 100644 index 00000000..8f727403 --- /dev/null +++ b/packaging/linux/debian/postinst @@ -0,0 +1,6 @@ +#!/bin/bash + +systemctl daemon-reload +systemctl enable portmaster.service + +echo "Please reboot your system" \ No newline at end of file diff --git a/packaging/linux/debian/postrm b/packaging/linux/debian/postrm new file mode 100644 index 00000000..a9bf588e --- /dev/null +++ b/packaging/linux/debian/postrm @@ -0,0 +1 @@ +#!/bin/bash diff --git a/packaging/linux/portmaster-autostart.desktop b/packaging/linux/portmaster-autostart.desktop new file mode 100644 index 00000000..4396d9c5 --- /dev/null +++ b/packaging/linux/portmaster-autostart.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Portmaster +GenericName=Application Firewall Notifier +Exec=/usr/bin/portmaster --with-prompts --with-notifications --background +Icon=portmaster +Terminal=false +Type=Application +Categories=System +NoDisplay=true \ No newline at end of file diff --git a/packaging/linux/portmaster.desktop b/packaging/linux/portmaster.desktop new file mode 100644 index 00000000..c21458b0 --- /dev/null +++ b/packaging/linux/portmaster.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Portmaster +GenericName=Application Firewall +Exec={{exec}} --data=/opt/safing/portmaster --with-prompts --with-notifications +Icon={{icon}} +Terminal=false +Type=Application +Categories=System diff --git a/packaging/linux/portmaster.service b/packaging/linux/portmaster.service new file mode 100644 index 00000000..81574193 --- /dev/null +++ b/packaging/linux/portmaster.service @@ -0,0 +1,41 @@ +[Unit] +Description=Portmaster by Safing +Documentation=https://safing.io +Documentation=https://docs.safing.io +Before=nss-lookup.target network.target shutdown.target +After=systemd-networkd.service +Conflicts=shutdown.target +Conflicts=firewalld.service +Wants=nss-lookup.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +RestartPreventExitStatus=24 +LockPersonality=yes +MemoryDenyWriteExecute=yes +NoNewPrivileges=yes +PrivateTmp=yes +PIDFile=/var/lib/portmaster/core-lock.pid +Environment=LOGLEVEL=info +Environment=PORTMASTER_ARGS= +EnvironmentFile=-/etc/default/portmaster +ProtectSystem=true +RestrictAddressFamilies=AF_UNIX AF_NETLINK AF_INET AF_INET6 +RestrictNamespaces=yes +ProtectHome=read-only +ProtectKernelTunables=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +PrivateDevices=yes +AmbientCapabilities=cap_chown cap_kill cap_net_admin cap_net_bind_service cap_net_broadcast cap_net_raw cap_sys_module cap_sys_ptrace cap_dac_override cap_fowner cap_fsetid +CapabilityBoundingSet=cap_chown cap_kill cap_net_admin cap_net_bind_service cap_net_broadcast cap_net_raw cap_sys_module cap_sys_ptrace cap_dac_override cap_fowner cap_fsetid +StateDirectory=portmaster +ExecStartPre=-/usr/bin/portmaster-start --data $STATE_DIRECTORY clean-structure +# TODO(ppacher): add --disable-software-updates once it's merged and the release process changed. +ExecStart=/usr/bin/portmaster-core --data $STATE_DIRECTORY $PORTMASTER_ARGS +ExecStartPost=-/usr/bin/portmaster-start recover-iptables + +[Install] +WantedBy=multi-user.target diff --git a/packaging/windows/.gitkeep b/packaging/windows/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/runtime/.gitkeep b/runtime/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/broadcasts/api.go b/service/broadcasts/api.go similarity index 100% rename from broadcasts/api.go rename to service/broadcasts/api.go diff --git a/broadcasts/data.go b/service/broadcasts/data.go similarity index 91% rename from broadcasts/data.go rename to service/broadcasts/data.go index a04c7820..22faf458 100644 --- a/broadcasts/data.go +++ b/service/broadcasts/data.go @@ -5,12 +5,12 @@ import ( "time" "github.com/safing/portbase/config" - "github.com/safing/portmaster/intel/geoip" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/updates" - "github.com/safing/spn/access" - "github.com/safing/spn/access/account" - "github.com/safing/spn/captain" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/access/account" + "github.com/safing/portmaster/spn/captain" ) var portmasterStarted = time.Now() diff --git a/broadcasts/install_info.go b/service/broadcasts/install_info.go similarity index 100% rename from broadcasts/install_info.go rename to service/broadcasts/install_info.go diff --git a/broadcasts/module.go b/service/broadcasts/module.go similarity index 100% rename from broadcasts/module.go rename to service/broadcasts/module.go diff --git a/broadcasts/notify.go b/service/broadcasts/notify.go similarity index 99% rename from broadcasts/notify.go rename to service/broadcasts/notify.go index cd6c38f2..4e359139 100644 --- a/broadcasts/notify.go +++ b/service/broadcasts/notify.go @@ -18,7 +18,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) const ( diff --git a/broadcasts/state.go b/service/broadcasts/state.go similarity index 100% rename from broadcasts/state.go rename to service/broadcasts/state.go diff --git a/broadcasts/testdata/README.md b/service/broadcasts/testdata/README.md similarity index 100% rename from broadcasts/testdata/README.md rename to service/broadcasts/testdata/README.md diff --git a/broadcasts/testdata/notifications.yaml b/service/broadcasts/testdata/notifications.yaml similarity index 100% rename from broadcasts/testdata/notifications.yaml rename to service/broadcasts/testdata/notifications.yaml diff --git a/compat/api.go b/service/compat/api.go similarity index 100% rename from compat/api.go rename to service/compat/api.go diff --git a/compat/callbacks.go b/service/compat/callbacks.go similarity index 90% rename from compat/callbacks.go rename to service/compat/callbacks.go index e997ff8f..2abfa858 100644 --- a/compat/callbacks.go +++ b/service/compat/callbacks.go @@ -3,8 +3,8 @@ package compat import ( "net" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" ) // SubmitSystemIntegrationCheckPacket submit a packet for the system integrity check. diff --git a/compat/debug_default.go b/service/compat/debug_default.go similarity index 100% rename from compat/debug_default.go rename to service/compat/debug_default.go diff --git a/compat/debug_linux.go b/service/compat/debug_linux.go similarity index 100% rename from compat/debug_linux.go rename to service/compat/debug_linux.go diff --git a/compat/debug_windows.go b/service/compat/debug_windows.go similarity index 100% rename from compat/debug_windows.go rename to service/compat/debug_windows.go diff --git a/compat/iptables.go b/service/compat/iptables.go similarity index 100% rename from compat/iptables.go rename to service/compat/iptables.go diff --git a/compat/iptables_test.go b/service/compat/iptables_test.go similarity index 100% rename from compat/iptables_test.go rename to service/compat/iptables_test.go diff --git a/compat/module.go b/service/compat/module.go similarity index 94% rename from compat/module.go rename to service/compat/module.go index ea5fcd69..b8b95090 100644 --- a/compat/module.go +++ b/service/compat/module.go @@ -2,14 +2,15 @@ package compat import ( "context" + "errors" "time" "github.com/tevino/abool" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/resolver" ) var ( @@ -92,6 +93,9 @@ func selfcheckTaskFunc(ctx context.Context, task *modules.Task) error { case err == nil: // Successful. tracer.Debugf("compat: self-check successful") + case errors.Is(err, errSelfcheckSkipped): + // Skipped. + tracer.Debugf("compat: %s", err) case issue == nil: // Internal error. tracer.Warningf("compat: %s", err) diff --git a/compat/notify.go b/service/compat/notify.go similarity index 98% rename from compat/notify.go rename to service/compat/notify.go index 39157648..f26f0ea3 100644 --- a/compat/notify.go +++ b/service/compat/notify.go @@ -12,8 +12,8 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" ) type baseIssue struct { diff --git a/compat/selfcheck.go b/service/compat/selfcheck.go similarity index 94% rename from compat/selfcheck.go rename to service/compat/selfcheck.go index fd4a22bc..f4775cdc 100644 --- a/compat/selfcheck.go +++ b/service/compat/selfcheck.go @@ -3,6 +3,7 @@ package compat import ( "context" "encoding/hex" + "errors" "fmt" "net" "strings" @@ -11,8 +12,9 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/rng" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/resolver" ) var ( @@ -36,12 +38,19 @@ var ( dnsCheckWaitDuration = 45 * time.Second dnsCheckAnswerLock sync.Mutex dnsCheckAnswer net.IP + + errSelfcheckSkipped = errors.New("self-check skipped") ) func selfcheck(ctx context.Context) (issue *systemIssue, err error) { selfcheckLock.Lock() defer selfcheckLock.Unlock() + // Step 0: Check if self-check makes sense. + if !netenv.Online() { + return nil, fmt.Errorf("%w: device is offline or in limited network", errSelfcheckSkipped) + } + // Step 1: Check if the system integration sees a packet. // Empty recv channel. diff --git a/compat/wfpstate.go b/service/compat/wfpstate.go similarity index 100% rename from compat/wfpstate.go rename to service/compat/wfpstate.go diff --git a/compat/wfpstate_test.go b/service/compat/wfpstate_test.go similarity index 100% rename from compat/wfpstate_test.go rename to service/compat/wfpstate_test.go diff --git a/core/api.go b/service/core/api.go similarity index 93% rename from core/api.go rename to service/core/api.go index 6a653909..abc43dad 100644 --- a/core/api.go +++ b/service/core/api.go @@ -3,6 +3,7 @@ package core import ( "context" "encoding/hex" + "errors" "fmt" "net/http" "net/url" @@ -15,14 +16,16 @@ import ( "github.com/safing/portbase/notifications" "github.com/safing/portbase/rng" "github.com/safing/portbase/utils/debug" - "github.com/safing/portmaster/compat" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/resolver" - "github.com/safing/portmaster/status" - "github.com/safing/portmaster/updates" - "github.com/safing/spn/captain" + "github.com/safing/portmaster/service/compat" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/resolver" + "github.com/safing/portmaster/service/status" + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/spn/captain" ) +var errInvalidReadPermission = errors.New("invalid read permission") + func registerAPIEndpoints() error { if err := api.RegisterEndpoint(api.Endpoint{ Path: "core/shutdown", @@ -207,10 +210,10 @@ func authorizeApp(ar *api.Request) (interface{}, error) { // convert the requested read and write permissions to their api.Permission // value. This ensures only "user" or "admin" permissions can be requested. if getSavePermission(readPermStr) <= api.NotSupported { - return nil, fmt.Errorf("invalid read permission") + return nil, errInvalidReadPermission } if getSavePermission(writePermStr) <= api.NotSupported { - return nil, fmt.Errorf("invalid read permission") + return nil, errInvalidReadPermission } proc, err := process.GetProcessByRequestOrigin(ar) @@ -281,7 +284,7 @@ func authorizeApp(ar *api.Request) (interface{}, error) { select { case key := <-ch: if len(key) == 0 { - return nil, fmt.Errorf("access denied") + return nil, errors.New("access denied") } return map[string]interface{}{ @@ -289,6 +292,6 @@ func authorizeApp(ar *api.Request) (interface{}, error) { "validUntil": validUntil, }, nil case <-ar.Context().Done(): - return nil, fmt.Errorf("timeout") + return nil, errors.New("timeout") } } diff --git a/core/base/databases.go b/service/core/base/databases.go similarity index 100% rename from core/base/databases.go rename to service/core/base/databases.go diff --git a/core/base/global.go b/service/core/base/global.go similarity index 100% rename from core/base/global.go rename to service/core/base/global.go diff --git a/core/base/logs.go b/service/core/base/logs.go similarity index 100% rename from core/base/logs.go rename to service/core/base/logs.go diff --git a/core/base/module.go b/service/core/base/module.go similarity index 100% rename from core/base/module.go rename to service/core/base/module.go diff --git a/core/base/profiling.go b/service/core/base/profiling.go similarity index 100% rename from core/base/profiling.go rename to service/core/base/profiling.go diff --git a/core/config.go b/service/core/config.go similarity index 100% rename from core/config.go rename to service/core/config.go diff --git a/core/core.go b/service/core/core.go similarity index 84% rename from core/core.go rename to service/core/core.go index d0c95418..ff535759 100644 --- a/core/core.go +++ b/service/core/core.go @@ -9,13 +9,13 @@ import ( "github.com/safing/portbase/metrics" "github.com/safing/portbase/modules" "github.com/safing/portbase/modules/subsystems" - _ "github.com/safing/portmaster/broadcasts" - _ "github.com/safing/portmaster/netenv" - _ "github.com/safing/portmaster/netquery" - _ "github.com/safing/portmaster/status" - _ "github.com/safing/portmaster/sync" - _ "github.com/safing/portmaster/ui" - "github.com/safing/portmaster/updates" + _ "github.com/safing/portmaster/service/broadcasts" + _ "github.com/safing/portmaster/service/netenv" + _ "github.com/safing/portmaster/service/netquery" + _ "github.com/safing/portmaster/service/status" + _ "github.com/safing/portmaster/service/sync" + _ "github.com/safing/portmaster/service/ui" + "github.com/safing/portmaster/service/updates" ) const ( diff --git a/core/os_default.go b/service/core/os_default.go similarity index 100% rename from core/os_default.go rename to service/core/os_default.go diff --git a/core/os_windows.go b/service/core/os_windows.go similarity index 100% rename from core/os_windows.go rename to service/core/os_windows.go diff --git a/core/pmtesting/testing.go b/service/core/pmtesting/testing.go similarity index 96% rename from core/pmtesting/testing.go rename to service/core/pmtesting/testing.go index 9b597c83..16253f86 100644 --- a/core/pmtesting/testing.go +++ b/service/core/pmtesting/testing.go @@ -7,7 +7,7 @@ // import ( // "testing" // -// "github.com/safing/portmaster/core/pmtesting" +// "github.com/safing/portmaster/service/core/pmtesting" // ) // // func TestMain(m *testing.M) { @@ -27,7 +27,7 @@ import ( "github.com/safing/portbase/dataroot" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/core/base" + "github.com/safing/portmaster/service/core/base" ) var printStackOnExit bool diff --git a/detection/dga/lms.go b/service/detection/dga/lms.go similarity index 100% rename from detection/dga/lms.go rename to service/detection/dga/lms.go diff --git a/detection/dga/lms_test.go b/service/detection/dga/lms_test.go similarity index 100% rename from detection/dga/lms_test.go rename to service/detection/dga/lms_test.go diff --git a/firewall/api.go b/service/firewall/api.go similarity index 93% rename from firewall/api.go rename to service/firewall/api.go index b17efe6d..244ec2b8 100644 --- a/firewall/api.go +++ b/service/firewall/api.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "path/filepath" + "slices" "strings" "time" @@ -13,11 +14,11 @@ import ( "github.com/safing/portbase/dataroot" "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/updates" ) const ( @@ -164,6 +165,12 @@ func authenticateAPIRequest(ctx context.Context, pktInfo *packet.Info) (retry bo default: // normal process // Check if the requesting process is in database root / updates dir. if realPath, err := filepath.EvalSymlinks(proc.Path); err == nil { + + // check if the client has been allowed by flag + if slices.Contains(allowedClients, realPath) { + return false, nil + } + if strings.HasPrefix(realPath, authenticatedPath) { return false, nil } diff --git a/firewall/bypassing.go b/service/firewall/bypassing.go similarity index 87% rename from firewall/bypassing.go rename to service/firewall/bypassing.go index cf8502cb..415fc6c8 100644 --- a/firewall/bypassing.go +++ b/service/firewall/bypassing.go @@ -4,11 +4,11 @@ import ( "context" "strings" - "github.com/safing/portmaster/compat" - "github.com/safing/portmaster/nameserver/nsutil" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/profile/endpoints" + "github.com/safing/portmaster/service/compat" + "github.com/safing/portmaster/service/nameserver/nsutil" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/profile/endpoints" ) var resolverFilterLists = []string{"17-DNS"} diff --git a/firewall/config.go b/service/firewall/config.go similarity index 98% rename from firewall/config.go rename to service/firewall/config.go index 4e3ca653..960c000b 100644 --- a/firewall/config.go +++ b/service/firewall/config.go @@ -6,8 +6,8 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/config" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/core" - "github.com/safing/spn/captain" + "github.com/safing/portmaster/service/core" + "github.com/safing/portmaster/spn/captain" ) // Configuration Keys. diff --git a/firewall/dns.go b/service/firewall/dns.go similarity index 95% rename from firewall/dns.go rename to service/firewall/dns.go index 498d3a52..3712165d 100644 --- a/firewall/dns.go +++ b/service/firewall/dns.go @@ -10,11 +10,11 @@ import ( "github.com/safing/portbase/database" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/endpoints" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/service/resolver" ) func filterDNSSection( @@ -177,15 +177,6 @@ func FilterResolvedDNS( return rrCache } - // Finalize verdict. - defer func() { - // Reset from previous filtering. - conn.Verdict.Active = network.VerdictUndecided - conn.Verdict.Worst = network.VerdictUndecided - // Update all values again. - finalizeVerdict(conn) - }() - // special grant for connectivity domains if checkConnectivityDomain(ctx, conn, layeredProfile, nil) { // returns true if check triggered @@ -197,7 +188,7 @@ func FilterResolvedDNS( // Filter dns records and return if the query is blocked. rrCache = filterDNSResponse(ctx, conn, layeredProfile, rrCache, sysResolver) - if conn.Verdict.Active == network.VerdictBlock { + if conn.Verdict == network.VerdictBlock { return rrCache } diff --git a/firewall/inspection/inspection.go b/service/firewall/inspection/inspection.go similarity index 91% rename from firewall/inspection/inspection.go rename to service/firewall/inspection/inspection.go index 9481932c..44855ba4 100644 --- a/firewall/inspection/inspection.go +++ b/service/firewall/inspection/inspection.go @@ -3,8 +3,8 @@ package inspection import ( "sync" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" ) //nolint:golint,stylecheck // FIXME @@ -64,7 +64,7 @@ func RunInspectors(conn *network.Connection, pkt packet.Packet) (network.Verdict } // check if the active verdict is already past the inspection criteria. - if conn.Verdict.Active > inspectVerdicts[key] { + if conn.Verdict > inspectVerdicts[key] { activeInspectors[key] = true continue } @@ -86,11 +86,11 @@ func RunInspectors(conn *network.Connection, pkt packet.Packet) (network.Verdict continueInspection = true case BLOCK_CONN: conn.SetVerdict(network.VerdictBlock, "", "", nil) - verdict = conn.Verdict.Active + verdict = conn.Verdict activeInspectors[key] = true case DROP_CONN: conn.SetVerdict(network.VerdictDrop, "", "", nil) - verdict = conn.Verdict.Active + verdict = conn.Verdict activeInspectors[key] = true case STOP_INSPECTING: activeInspectors[key] = true diff --git a/firewall/interception/ebpf/bandwidth/bpf_bpfeb.go b/service/firewall/interception/ebpf/bandwidth/bpf_bpfeb.go similarity index 100% rename from firewall/interception/ebpf/bandwidth/bpf_bpfeb.go rename to service/firewall/interception/ebpf/bandwidth/bpf_bpfeb.go diff --git a/firewall/interception/ebpf/bandwidth/bpf_bpfeb.o b/service/firewall/interception/ebpf/bandwidth/bpf_bpfeb.o similarity index 100% rename from firewall/interception/ebpf/bandwidth/bpf_bpfeb.o rename to service/firewall/interception/ebpf/bandwidth/bpf_bpfeb.o diff --git a/firewall/interception/ebpf/bandwidth/bpf_bpfel.go b/service/firewall/interception/ebpf/bandwidth/bpf_bpfel.go similarity index 100% rename from firewall/interception/ebpf/bandwidth/bpf_bpfel.go rename to service/firewall/interception/ebpf/bandwidth/bpf_bpfel.go diff --git a/firewall/interception/ebpf/bandwidth/bpf_bpfel.o b/service/firewall/interception/ebpf/bandwidth/bpf_bpfel.o similarity index 100% rename from firewall/interception/ebpf/bandwidth/bpf_bpfel.o rename to service/firewall/interception/ebpf/bandwidth/bpf_bpfel.o diff --git a/firewall/interception/ebpf/bandwidth/interface.go b/service/firewall/interception/ebpf/bandwidth/interface.go similarity index 98% rename from firewall/interception/ebpf/bandwidth/interface.go rename to service/firewall/interception/ebpf/bandwidth/interface.go index 3a08bbad..e1473dbd 100644 --- a/firewall/interception/ebpf/bandwidth/interface.go +++ b/service/firewall/interception/ebpf/bandwidth/interface.go @@ -16,7 +16,7 @@ import ( "golang.org/x/sys/unix" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" bpf ../programs/bandwidth.c diff --git a/firewall/interception/ebpf/connection_listener/bpf_bpfeb.go b/service/firewall/interception/ebpf/connection_listener/bpf_bpfeb.go similarity index 100% rename from firewall/interception/ebpf/connection_listener/bpf_bpfeb.go rename to service/firewall/interception/ebpf/connection_listener/bpf_bpfeb.go diff --git a/firewall/interception/ebpf/connection_listener/bpf_bpfeb.o b/service/firewall/interception/ebpf/connection_listener/bpf_bpfeb.o similarity index 100% rename from firewall/interception/ebpf/connection_listener/bpf_bpfeb.o rename to service/firewall/interception/ebpf/connection_listener/bpf_bpfeb.o diff --git a/firewall/interception/ebpf/connection_listener/bpf_bpfel.go b/service/firewall/interception/ebpf/connection_listener/bpf_bpfel.go similarity index 100% rename from firewall/interception/ebpf/connection_listener/bpf_bpfel.go rename to service/firewall/interception/ebpf/connection_listener/bpf_bpfel.go diff --git a/firewall/interception/ebpf/connection_listener/bpf_bpfel.o b/service/firewall/interception/ebpf/connection_listener/bpf_bpfel.o similarity index 100% rename from firewall/interception/ebpf/connection_listener/bpf_bpfel.o rename to service/firewall/interception/ebpf/connection_listener/bpf_bpfel.o diff --git a/firewall/interception/ebpf/connection_listener/worker.go b/service/firewall/interception/ebpf/connection_listener/worker.go similarity index 98% rename from firewall/interception/ebpf/connection_listener/worker.go rename to service/firewall/interception/ebpf/connection_listener/worker.go index bee03f12..aadfd57f 100644 --- a/firewall/interception/ebpf/connection_listener/worker.go +++ b/service/firewall/interception/ebpf/connection_listener/worker.go @@ -15,7 +15,7 @@ import ( "github.com/cilium/ebpf/rlimit" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" -type Event bpf ../programs/monitor.c diff --git a/firewall/interception/ebpf/exec/bpf_bpfeb.go b/service/firewall/interception/ebpf/exec/bpf_bpfeb.go similarity index 100% rename from firewall/interception/ebpf/exec/bpf_bpfeb.go rename to service/firewall/interception/ebpf/exec/bpf_bpfeb.go diff --git a/firewall/interception/ebpf/exec/bpf_bpfeb.o b/service/firewall/interception/ebpf/exec/bpf_bpfeb.o similarity index 100% rename from firewall/interception/ebpf/exec/bpf_bpfeb.o rename to service/firewall/interception/ebpf/exec/bpf_bpfeb.o diff --git a/firewall/interception/ebpf/exec/bpf_bpfel.go b/service/firewall/interception/ebpf/exec/bpf_bpfel.go similarity index 100% rename from firewall/interception/ebpf/exec/bpf_bpfel.go rename to service/firewall/interception/ebpf/exec/bpf_bpfel.go diff --git a/firewall/interception/ebpf/exec/bpf_bpfel.o b/service/firewall/interception/ebpf/exec/bpf_bpfel.o similarity index 100% rename from firewall/interception/ebpf/exec/bpf_bpfel.o rename to service/firewall/interception/ebpf/exec/bpf_bpfel.o diff --git a/firewall/interception/ebpf/exec/exec.go b/service/firewall/interception/ebpf/exec/exec.go similarity index 100% rename from firewall/interception/ebpf/exec/exec.go rename to service/firewall/interception/ebpf/exec/exec.go diff --git a/firewall/interception/ebpf/programs/bandwidth.c b/service/firewall/interception/ebpf/programs/bandwidth.c similarity index 100% rename from firewall/interception/ebpf/programs/bandwidth.c rename to service/firewall/interception/ebpf/programs/bandwidth.c diff --git a/firewall/interception/ebpf/programs/bpf/bpf_core_read.h b/service/firewall/interception/ebpf/programs/bpf/bpf_core_read.h similarity index 100% rename from firewall/interception/ebpf/programs/bpf/bpf_core_read.h rename to service/firewall/interception/ebpf/programs/bpf/bpf_core_read.h diff --git a/firewall/interception/ebpf/programs/bpf/bpf_helper_defs.h b/service/firewall/interception/ebpf/programs/bpf/bpf_helper_defs.h similarity index 100% rename from firewall/interception/ebpf/programs/bpf/bpf_helper_defs.h rename to service/firewall/interception/ebpf/programs/bpf/bpf_helper_defs.h diff --git a/firewall/interception/ebpf/programs/bpf/bpf_helpers.h b/service/firewall/interception/ebpf/programs/bpf/bpf_helpers.h similarity index 100% rename from firewall/interception/ebpf/programs/bpf/bpf_helpers.h rename to service/firewall/interception/ebpf/programs/bpf/bpf_helpers.h diff --git a/firewall/interception/ebpf/programs/bpf/bpf_tracing.h b/service/firewall/interception/ebpf/programs/bpf/bpf_tracing.h similarity index 100% rename from firewall/interception/ebpf/programs/bpf/bpf_tracing.h rename to service/firewall/interception/ebpf/programs/bpf/bpf_tracing.h diff --git a/firewall/interception/ebpf/programs/exec.c b/service/firewall/interception/ebpf/programs/exec.c similarity index 100% rename from firewall/interception/ebpf/programs/exec.c rename to service/firewall/interception/ebpf/programs/exec.c diff --git a/firewall/interception/ebpf/programs/monitor.c b/service/firewall/interception/ebpf/programs/monitor.c similarity index 100% rename from firewall/interception/ebpf/programs/monitor.c rename to service/firewall/interception/ebpf/programs/monitor.c diff --git a/firewall/interception/ebpf/programs/update.sh b/service/firewall/interception/ebpf/programs/update.sh similarity index 100% rename from firewall/interception/ebpf/programs/update.sh rename to service/firewall/interception/ebpf/programs/update.sh diff --git a/firewall/interception/ebpf/programs/vmlinux-x86.h b/service/firewall/interception/ebpf/programs/vmlinux-x86.h similarity index 100% rename from firewall/interception/ebpf/programs/vmlinux-x86.h rename to service/firewall/interception/ebpf/programs/vmlinux-x86.h diff --git a/firewall/interception/interception_default.go b/service/firewall/interception/interception_default.go similarity index 87% rename from firewall/interception/interception_default.go rename to service/firewall/interception/interception_default.go index 222a041c..a4a93f44 100644 --- a/firewall/interception/interception_default.go +++ b/service/firewall/interception/interception_default.go @@ -4,8 +4,8 @@ package interception import ( "github.com/safing/portbase/log" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" ) // start starts the interception. diff --git a/firewall/interception/interception_linux.go b/service/firewall/interception/interception_linux.go similarity index 77% rename from firewall/interception/interception_linux.go rename to service/firewall/interception/interception_linux.go index 128f6649..66ca5b7e 100644 --- a/firewall/interception/interception_linux.go +++ b/service/firewall/interception/interception_linux.go @@ -4,11 +4,11 @@ import ( "context" "time" - bandwidth "github.com/safing/portmaster/firewall/interception/ebpf/bandwidth" - conn_listener "github.com/safing/portmaster/firewall/interception/ebpf/connection_listener" - "github.com/safing/portmaster/firewall/interception/nfq" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" + bandwidth "github.com/safing/portmaster/service/firewall/interception/ebpf/bandwidth" + conn_listener "github.com/safing/portmaster/service/firewall/interception/ebpf/connection_listener" + "github.com/safing/portmaster/service/firewall/interception/nfq" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" ) // start starts the interception. diff --git a/firewall/interception/interception_windows.go b/service/firewall/interception/interception_windows.go similarity index 100% rename from firewall/interception/interception_windows.go rename to service/firewall/interception/interception_windows.go diff --git a/firewall/interception/introspection.go b/service/firewall/interception/introspection.go similarity index 100% rename from firewall/interception/introspection.go rename to service/firewall/interception/introspection.go diff --git a/firewall/interception/module.go b/service/firewall/interception/module.go similarity index 96% rename from firewall/interception/module.go rename to service/firewall/interception/module.go index 0b0e86d0..2802defa 100644 --- a/firewall/interception/module.go +++ b/service/firewall/interception/module.go @@ -5,7 +5,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) var ( diff --git a/firewall/interception/nfq/conntrack.go b/service/firewall/interception/nfq/conntrack.go similarity index 93% rename from firewall/interception/nfq/conntrack.go rename to service/firewall/interception/nfq/conntrack.go index b71651ec..ea7761e4 100644 --- a/firewall/interception/nfq/conntrack.go +++ b/service/firewall/interception/nfq/conntrack.go @@ -4,13 +4,14 @@ package nfq import ( "encoding/binary" + "errors" "fmt" ct "github.com/florianl/go-conntrack" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network" ) var nfct *ct.Nfct // Conntrack handler. NFCT: Network Filter Connection Tracking. @@ -35,7 +36,7 @@ func TeardownNFCT() { // DeleteAllMarkedConnection deletes all marked entries from the conntrack table. func DeleteAllMarkedConnection() error { if nfct == nil { - return fmt.Errorf("nfq: nfct not initialized") + return errors.New("nfq: nfct not initialized") } // Delete all ipv4 marked connections @@ -87,7 +88,7 @@ func deleteMarkedConnections(nfct *ct.Nfct, f ct.Family) (deleted int) { // DeleteMarkedConnection removes a specific connection from the conntrack table. func DeleteMarkedConnection(conn *network.Connection) error { if nfct == nil { - return fmt.Errorf("nfq: nfct not initialized") + return errors.New("nfq: nfct not initialized") } con := ct.Con{ diff --git a/firewall/interception/nfq/nfq.go b/service/firewall/interception/nfq/nfq.go similarity index 98% rename from firewall/interception/nfq/nfq.go rename to service/firewall/interception/nfq/nfq.go index 184e15f9..f7579920 100644 --- a/firewall/interception/nfq/nfq.go +++ b/service/firewall/interception/nfq/nfq.go @@ -15,8 +15,8 @@ import ( "golang.org/x/sys/unix" "github.com/safing/portbase/log" - pmpacket "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" + pmpacket "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" ) // Queue wraps a nfqueue. diff --git a/firewall/interception/nfq/packet.go b/service/firewall/interception/nfq/packet.go similarity index 98% rename from firewall/interception/nfq/packet.go rename to service/firewall/interception/nfq/packet.go index 8baeff5b..af3d5fac 100644 --- a/firewall/interception/nfq/packet.go +++ b/service/firewall/interception/nfq/packet.go @@ -11,7 +11,7 @@ import ( "github.com/tevino/abool" "github.com/safing/portbase/log" - pmpacket "github.com/safing/portmaster/network/packet" + pmpacket "github.com/safing/portmaster/service/network/packet" ) // Firewalling marks used by the Portmaster. diff --git a/firewall/interception/nfqueue_linux.go b/service/firewall/interception/nfqueue_linux.go similarity index 98% rename from firewall/interception/nfqueue_linux.go rename to service/firewall/interception/nfqueue_linux.go index 2e632813..537bbcb7 100644 --- a/firewall/interception/nfqueue_linux.go +++ b/service/firewall/interception/nfqueue_linux.go @@ -11,9 +11,9 @@ import ( "github.com/hashicorp/go-multierror" "github.com/safing/portbase/log" - "github.com/safing/portmaster/firewall/interception/nfq" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/firewall/interception/nfq" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/packet" ) var ( diff --git a/firewall/interception/packet_tracer.go b/service/firewall/interception/packet_tracer.go similarity index 95% rename from firewall/interception/packet_tracer.go rename to service/firewall/interception/packet_tracer.go index 4d822a42..b90dfbf7 100644 --- a/firewall/interception/packet_tracer.go +++ b/service/firewall/interception/packet_tracer.go @@ -3,7 +3,7 @@ package interception import ( "time" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) type tracedPacket struct { diff --git a/firewall/interception/windowskext/bandwidth_stats.go b/service/firewall/interception/windowskext/bandwidth_stats.go similarity index 98% rename from firewall/interception/windowskext/bandwidth_stats.go rename to service/firewall/interception/windowskext/bandwidth_stats.go index 482d81b6..a29e50d9 100644 --- a/firewall/interception/windowskext/bandwidth_stats.go +++ b/service/firewall/interception/windowskext/bandwidth_stats.go @@ -10,7 +10,7 @@ import ( "time" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) type Rxtxdata struct { diff --git a/firewall/interception/windowskext/doc.go b/service/firewall/interception/windowskext/doc.go similarity index 100% rename from firewall/interception/windowskext/doc.go rename to service/firewall/interception/windowskext/doc.go diff --git a/firewall/interception/windowskext/handler.go b/service/firewall/interception/windowskext/handler.go similarity index 97% rename from firewall/interception/windowskext/handler.go rename to service/firewall/interception/windowskext/handler.go index f5d66761..a5a8de74 100644 --- a/firewall/interception/windowskext/handler.go +++ b/service/firewall/interception/windowskext/handler.go @@ -12,13 +12,13 @@ import ( "time" "unsafe" - "github.com/safing/portmaster/process" + "github.com/safing/portmaster/service/process" "github.com/tevino/abool" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" ) const ( diff --git a/firewall/interception/windowskext/kext.go b/service/firewall/interception/windowskext/kext.go similarity index 98% rename from firewall/interception/windowskext/kext.go rename to service/firewall/interception/windowskext/kext.go index 6ab6ee28..34badd6d 100644 --- a/firewall/interception/windowskext/kext.go +++ b/service/firewall/interception/windowskext/kext.go @@ -11,8 +11,8 @@ import ( "unsafe" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" "golang.org/x/sys/windows" ) @@ -263,7 +263,7 @@ func UpdateVerdict(conn *network.Connection) error { localPort: conn.LocalPort, remoteIP: ipAddressToArray(conn.Entity.IP, isIpv6 == 1), remotePort: conn.Entity.Port, - verdict: uint8(conn.Verdict.Active), + verdict: uint8(conn.Verdict), } // Make driver request diff --git a/firewall/interception/windowskext/packet.go b/service/firewall/interception/windowskext/packet.go similarity index 97% rename from firewall/interception/windowskext/packet.go rename to service/firewall/interception/windowskext/packet.go index 6c7b24da..5f96e784 100644 --- a/firewall/interception/windowskext/packet.go +++ b/service/firewall/interception/windowskext/packet.go @@ -9,8 +9,8 @@ import ( "github.com/tevino/abool" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" ) // Packet represents an IP packet. diff --git a/firewall/interception/windowskext/service.go b/service/firewall/interception/windowskext/service.go similarity index 95% rename from firewall/interception/windowskext/service.go rename to service/firewall/interception/windowskext/service.go index facba765..e3e4ac2a 100644 --- a/firewall/interception/windowskext/service.go +++ b/service/firewall/interception/windowskext/service.go @@ -48,103 +48,103 @@ func createKextService(driverName string, driverPath string) (*KextService, erro // Create the service service, err = windows.CreateService(manager, &driverNameU16[0], &driverNameU16[0], windows.SERVICE_ALL_ACCESS, windows.SERVICE_KERNEL_DRIVER, windows.SERVICE_DEMAND_START, windows.SERVICE_ERROR_NORMAL, &driverPathU16[0], nil, nil, nil, nil, nil) - if err != nil { - return nil, err - } - - return &KextService{handle: service}, nil -} - -func deleteService(manager windows.Handle, service *KextService, driverName []uint16) error { - // Stop and wait before deleting - _ = service.stop(true) - - // Try to delete even if stop failed - err := service.delete() - if err != nil { - return fmt.Errorf("failed to delete old service: %s", err) - } - - // Wait until we can no longer open the old service. - // Not very efficient but NotifyServiceStatusChange cannot be used with driver service. - start := time.Now() - timeLimit := time.Duration(30 * time.Second) - for { - handle, err := windows.OpenService(manager, &driverName[0], windows.SERVICE_ALL_ACCESS) - if err != nil { - break - } - _ = windows.CloseServiceHandle(handle) - - if time.Since(start) > timeLimit { - return fmt.Errorf("time limit reached") - } - - time.Sleep(100 * time.Millisecond) - } - return nil -} - -func (s *KextService) isValid() bool { - return s != nil && s.handle != winInvalidHandleValue && s.handle != 0 -} - -func (s *KextService) isRunning() (bool, error) { - if !s.isValid() { - return false, fmt.Errorf("kext service not initialized") - } - var status windows.SERVICE_STATUS - err := windows.QueryServiceStatus(s.handle, &status) - if err != nil { - return false, err - } - return status.CurrentState == windows.SERVICE_RUNNING, nil -} - -func waitForServiceStatus(handle windows.Handle, neededStatus uint32, timeLimit time.Duration) (bool, error) { - var status windows.SERVICE_STATUS - status.CurrentState = windows.SERVICE_NO_CHANGE - start := time.Now() - for status.CurrentState == neededStatus { - err := windows.QueryServiceStatus(handle, &status) - if err != nil { - return false, fmt.Errorf("failed while waiting for service to start: %w", err) - } - - if time.Since(start) > timeLimit { - return false, fmt.Errorf("time limit reached") - } - - // Sleep for 1/10 of the wait hint, recommended time from microsoft - time.Sleep(time.Duration((status.WaitHint / 10)) * time.Millisecond) - } - - return true, nil -} - -func (s *KextService) start(wait bool) error { - if !s.isValid() { - return fmt.Errorf("kext service not initialized") - } - - // Start the service: - err := windows.StartService(s.handle, 0, nil) - - if err != nil { - err = windows.GetLastError() - if err != windows.ERROR_SERVICE_ALREADY_RUNNING { - // Failed to start service; clean-up: - var status windows.SERVICE_STATUS - _ = windows.ControlService(s.handle, windows.SERVICE_CONTROL_STOP, &status) - _ = windows.DeleteService(s.handle) - _ = windows.CloseServiceHandle(s.handle) - s.handle = winInvalidHandleValue - return err - } - } - - // Wait for service to start - if wait { + if err != nil { + return nil, err + } + + return &KextService{handle: service}, nil +} + +func deleteService(manager windows.Handle, service *KextService, driverName []uint16) error { + // Stop and wait before deleting + _ = service.stop(true) + + // Try to delete even if stop failed + err := service.delete() + if err != nil { + return fmt.Errorf("failed to delete old service: %s", err) + } + + // Wait until we can no longer open the old service. + // Not very efficient but NotifyServiceStatusChange cannot be used with driver service. + start := time.Now() + timeLimit := time.Duration(30 * time.Second) + for { + handle, err := windows.OpenService(manager, &driverName[0], windows.SERVICE_ALL_ACCESS) + if err != nil { + break + } + _ = windows.CloseServiceHandle(handle) + + if time.Since(start) > timeLimit { + return fmt.Errorf("time limit reached") + } + + time.Sleep(100 * time.Millisecond) + } + return nil +} + +func (s *KextService) isValid() bool { + return s != nil && s.handle != winInvalidHandleValue && s.handle != 0 +} + +func (s *KextService) isRunning() (bool, error) { + if !s.isValid() { + return false, fmt.Errorf("kext service not initialized") + } + var status windows.SERVICE_STATUS + err := windows.QueryServiceStatus(s.handle, &status) + if err != nil { + return false, err + } + return status.CurrentState == windows.SERVICE_RUNNING, nil +} + +func waitForServiceStatus(handle windows.Handle, neededStatus uint32, timeLimit time.Duration) (bool, error) { + var status windows.SERVICE_STATUS + status.CurrentState = windows.SERVICE_NO_CHANGE + start := time.Now() + for status.CurrentState != neededStatus { + err := windows.QueryServiceStatus(handle, &status) + if err != nil { + return false, fmt.Errorf("failed while waiting for service to start: %w", err) + } + + if time.Since(start) > timeLimit { + return false, fmt.Errorf("time limit reached") + } + + // Sleep for 1/10 of the wait hint, recommended time from microsoft + time.Sleep(time.Duration((status.WaitHint / 10)) * time.Millisecond) + } + + return true, nil +} + +func (s *KextService) start(wait bool) error { + if !s.isValid() { + return fmt.Errorf("kext service not initialized") + } + + // Start the service: + err := windows.StartService(s.handle, 0, nil) + + if err != nil { + err = windows.GetLastError() + if err != windows.ERROR_SERVICE_ALREADY_RUNNING { + // Failed to start service; clean-up: + var status windows.SERVICE_STATUS + _ = windows.ControlService(s.handle, windows.SERVICE_CONTROL_STOP, &status) + _ = windows.DeleteService(s.handle) + _ = windows.CloseServiceHandle(s.handle) + s.handle = winInvalidHandleValue + return err + } + } + + // Wait for service to start + if wait { success, err := waitForServiceStatus(s.handle, windows.SERVICE_RUNNING, time.Duration(10*time.Second)) if err != nil || !success { return fmt.Errorf("service did not start: %w", err) diff --git a/firewall/interception/windowskext/syscall.go b/service/firewall/interception/windowskext/syscall.go similarity index 100% rename from firewall/interception/windowskext/syscall.go rename to service/firewall/interception/windowskext/syscall.go diff --git a/firewall/master.go b/service/firewall/master.go similarity index 97% rename from firewall/master.go rename to service/firewall/master.go index 4183d561..6549194f 100644 --- a/firewall/master.go +++ b/service/firewall/master.go @@ -12,15 +12,15 @@ import ( "golang.org/x/net/publicsuffix" "github.com/safing/portbase/log" - "github.com/safing/portmaster/detection/dga" - "github.com/safing/portmaster/intel/customlists" - "github.com/safing/portmaster/intel/filterlists" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/endpoints" + "github.com/safing/portmaster/service/detection/dga" + "github.com/safing/portmaster/service/intel/customlists" + "github.com/safing/portmaster/service/intel/filterlists" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" ) const noReasonOptionKey = "" @@ -103,7 +103,7 @@ func decideOnConnection(ctx context.Context, conn *network.Connection, pkt packe case profile.DefaultActionAsk: // Only prompt if there has not been a decision already. // This prevents prompts from being created when re-evaluating connections. - if conn.Verdict.Firewall == network.VerdictUndecided { + if conn.Verdict == network.VerdictUndecided { prompt(ctx, conn) } default: diff --git a/firewall/module.go b/service/firewall/module.go similarity index 84% rename from firewall/module.go rename to service/firewall/module.go index de6ca88a..168ee7b8 100644 --- a/firewall/module.go +++ b/service/firewall/module.go @@ -2,21 +2,37 @@ package firewall import ( "context" + "flag" "fmt" + "path/filepath" "strings" "github.com/safing/portbase/config" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" "github.com/safing/portbase/modules/subsystems" - _ "github.com/safing/portmaster/core" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/profile" - "github.com/safing/spn/access" - "github.com/safing/spn/captain" + _ "github.com/safing/portmaster/service/core" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/captain" ) -var module *modules.Module +type stringSliceFlag []string + +func (ss *stringSliceFlag) String() string { + return strings.Join(*ss, ":") +} + +func (ss *stringSliceFlag) Set(value string) error { + *ss = append(*ss, filepath.Clean(value)) + return nil +} + +var ( + module *modules.Module + allowedClients stringSliceFlag +) func init() { module = modules.Register("filter", prep, start, stop, "core", "interception", "intel", "netquery") @@ -28,6 +44,8 @@ func init() { "config:filter/", nil, ) + + flag.Var(&allowedClients, "allowed-clients", "A list of binaries that are allowed to connect to the Portmaster API") } func prep() error { diff --git a/firewall/packet_handler.go b/service/firewall/packet_handler.go similarity index 85% rename from firewall/packet_handler.go rename to service/firewall/packet_handler.go index 093df319..46cc83f0 100644 --- a/firewall/packet_handler.go +++ b/service/firewall/packet_handler.go @@ -13,18 +13,18 @@ import ( "github.com/tevino/abool" "github.com/safing/portbase/log" - "github.com/safing/portmaster/compat" - _ "github.com/safing/portmaster/core/base" - "github.com/safing/portmaster/firewall/inspection" - "github.com/safing/portmaster/firewall/interception" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/netquery" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/reference" - "github.com/safing/portmaster/process" - "github.com/safing/spn/access" + "github.com/safing/portmaster/service/compat" + _ "github.com/safing/portmaster/service/core/base" + "github.com/safing/portmaster/service/firewall/inspection" + "github.com/safing/portmaster/service/firewall/interception" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/netquery" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/reference" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/spn/access" ) var ( @@ -132,13 +132,13 @@ func resetConnectionVerdict(ctx context.Context, conn *network.Connection) (verd } tracer.Debugf("filter: re-evaluating verdict of %s", conn) - previousVerdict := conn.Verdict.Firewall + previousVerdict := conn.Verdict // Apply privacy filter and check tunneling. FilterConnection(ctx, conn, nil, true, true) // Stop existing SPN tunnel if not needed anymore. - if conn.Verdict.Active != network.VerdictRerouteToTunnel && conn.TunnelContext != nil { + if conn.Verdict != network.VerdictRerouteToTunnel && conn.TunnelContext != nil { err := conn.TunnelContext.StopTunnel() if err != nil { tracer.Debugf("filter: failed to stopped unneeded tunnel: %s", err) @@ -146,7 +146,11 @@ func resetConnectionVerdict(ctx context.Context, conn *network.Connection) (verd } // Save if verdict changed. - if conn.Verdict.Firewall != previousVerdict { + if conn.Verdict != previousVerdict { + err := interception.UpdateVerdictOfConnection(conn) + if err != nil { + log.Debugf("filter: failed to update connection verdict: %s", err) + } conn.Save() tracer.Infof("filter: verdict of connection %s changed from %s to %s", conn, previousVerdict.Verb(), conn.VerdictVerb()) @@ -368,16 +372,17 @@ func fastTrackHandler(conn *network.Connection, pkt packet.Packet) { fastTrackedVerdict, permanent := fastTrackedPermit(conn, pkt) if fastTrackedVerdict != network.VerdictUndecided { // Set verdict on connection. - conn.Verdict.Active = fastTrackedVerdict - conn.Verdict.Firewall = fastTrackedVerdict + conn.Verdict = fastTrackedVerdict + // Apply verdict to (real) packet. if !pkt.InfoOnly() { issueVerdict(conn, pkt, fastTrackedVerdict, permanent) } + // Stop handler if permanent. if permanent { conn.SetVerdict(fastTrackedVerdict, "fast-tracked", "", nil) - conn.Verdict.Worst = fastTrackedVerdict + // Do not finalize verdict, as we are missing necessary data. conn.StopFirewallHandler() } @@ -447,7 +452,7 @@ func filterHandler(conn *network.Connection, pkt packet.Packet) { // End directly, as no other processing is necessary. conn.StopFirewallHandler() - finalizeVerdict(conn) + issueVerdict(conn, pkt, 0, true) return } @@ -504,19 +509,17 @@ func FilterConnection(ctx context.Context, conn *network.Connection, pkt packet. checkTunneling(ctx, conn) } - // Handle verdict records and transitions. - finalizeVerdict(conn) - // Request tunneling if no tunnel is set and connection should be tunneled. - if conn.Verdict.Active == network.VerdictRerouteToTunnel && + if conn.Verdict == network.VerdictRerouteToTunnel && conn.TunnelContext == nil { err := requestTunneling(ctx, conn) - if err != nil { + if err == nil { + conn.ConnectionEstablished = true + } else { // Set connection to failed, but keep tunneling data. // The tunneling data makes connection easy to recognize as a failed SPN // connection and the data will help with debugging and displaying in the UI. conn.Failed(fmt.Sprintf("failed to request tunneling: %s", err), "") - finalizeVerdict(conn) } } } @@ -554,17 +557,23 @@ func issueVerdict(conn *network.Connection, pkt packet.Packet, verdict network.V return } - // enable permanent verdict + // Enable permanent verdict. if allowPermanent && !conn.VerdictPermanent { - conn.VerdictPermanent = permanentVerdicts() - if conn.VerdictPermanent { + switch { + case !permanentVerdicts(): + // Permanent verdicts are disabled by configuration. + case conn.Entity != nil && reference.IsICMP(conn.Entity.Protocol): + case pkt != nil && reference.IsICMP(uint8(pkt.Info().Protocol)): + // ICMP is handled differently based on payload, so we cannot use persistent verdicts. + default: + conn.VerdictPermanent = true conn.SaveWhenFinished() } } // do not allow to circumvent decision: e.g. to ACCEPT packets from a DROP-ed connection - if verdict < conn.Verdict.Active { - verdict = conn.Verdict.Active + if verdict < conn.Verdict { + verdict = conn.Verdict } var err error @@ -610,65 +619,6 @@ func issueVerdict(conn *network.Connection, pkt packet.Packet, verdict network.V } } -// verdictRating rates the privacy and security aspect of verdicts from worst to best. -var verdictRating = []network.Verdict{ - network.VerdictAccept, // Connection allowed in the open. - network.VerdictRerouteToTunnel, // Connection allowed, but protected. - network.VerdictRerouteToNameserver, // Connection allowed, but resolved via Portmaster. - network.VerdictBlock, // Connection blocked, with feedback. - network.VerdictDrop, // Connection blocked, without feedback. - network.VerdictFailed, - network.VerdictUndeterminable, - network.VerdictUndecided, -} - -func finalizeVerdict(conn *network.Connection) { - // Update worst verdict at the end. - defer func() { - for _, worstVerdict := range verdictRating { - if conn.Verdict.Firewall == worstVerdict { - conn.Verdict.Worst = worstVerdict - } - } - }() - - // Check for non-applicable verdicts. - // The earlier and clearer we do this, the better. - switch conn.Verdict.Firewall { //nolint:exhaustive - case network.VerdictUndecided, network.VerdictUndeterminable, network.VerdictFailed: - if conn.Inbound { - conn.Verdict.Active = network.VerdictDrop - } else { - conn.Verdict.Active = network.VerdictBlock - } - return - } - - // Apply firewall verdict to active verdict. - switch { - case conn.Verdict.Active == network.VerdictUndecided: - // Apply first verdict without change. - conn.Verdict.Active = conn.Verdict.Firewall - - case conn.Verdict.Worst == network.VerdictBlock || - conn.Verdict.Worst == network.VerdictDrop || - conn.Verdict.Worst == network.VerdictFailed || - conn.Verdict.Worst == network.VerdictUndeterminable: - // Always allow to change verdict from any real initial/worst non-allowed state. - // Note: This check needs to happen before updating the Worst verdict. - conn.Verdict.Active = conn.Verdict.Firewall - - case reference.IsPacketProtocol(conn.Entity.Protocol): - // For known packet protocols, apply firewall verdict unchanged. - conn.Verdict.Active = conn.Verdict.Firewall - - case conn.Verdict.Active != conn.Verdict.Firewall: - // For all other protocols (most notably, stream protocols), always block after the first change. - // Block in both directions, as there is a live connection, which we want to actively kill. - conn.Verdict.Active = network.VerdictBlock - } -} - // func tunnelHandler(pkt packet.Packet) { // tunnelInfo := GetTunnelInfo(pkt.Info().Dst) // if tunnelInfo == nil { diff --git a/firewall/preauth.go b/service/firewall/preauth.go similarity index 93% rename from firewall/preauth.go rename to service/firewall/preauth.go index 3ee749a6..a265350f 100644 --- a/firewall/preauth.go +++ b/service/firewall/preauth.go @@ -6,10 +6,10 @@ import ( "strconv" "sync" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/resolver" ) var ( diff --git a/firewall/prompt.go b/service/firewall/prompt.go similarity index 97% rename from firewall/prompt.go rename to service/firewall/prompt.go index 0b2b4ef7..51d6a12a 100644 --- a/firewall/prompt.go +++ b/service/firewall/prompt.go @@ -8,10 +8,10 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/endpoints" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" ) const ( diff --git a/firewall/tunnel.go b/service/firewall/tunnel.go similarity index 91% rename from firewall/tunnel.go rename to service/firewall/tunnel.go index cadab2ea..46b5864a 100644 --- a/firewall/tunnel.go +++ b/service/firewall/tunnel.go @@ -5,18 +5,18 @@ import ( "errors" "github.com/safing/portbase/log" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/endpoints" - "github.com/safing/portmaster/resolver" - "github.com/safing/spn/captain" - "github.com/safing/spn/crew" - "github.com/safing/spn/navigator" - "github.com/safing/spn/sluice" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/service/resolver" + "github.com/safing/portmaster/spn/captain" + "github.com/safing/portmaster/spn/crew" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/sluice" ) func checkTunneling(ctx context.Context, conn *network.Connection) { @@ -31,7 +31,7 @@ func checkTunneling(ctx context.Context, conn *network.Connection) { case conn.Inbound: // Can't tunnel incoming connections. return - case conn.Verdict.Firewall != network.VerdictAccept: + case conn.Verdict != network.VerdictAccept: // Connection will be blocked. return case conn.IPProtocol != packet.TCP && conn.IPProtocol != packet.UDP: diff --git a/intel/block_reason.go b/service/intel/block_reason.go similarity index 97% rename from intel/block_reason.go rename to service/intel/block_reason.go index b29ef279..5cabbddf 100644 --- a/intel/block_reason.go +++ b/service/intel/block_reason.go @@ -9,7 +9,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/nameserver/nsutil" + "github.com/safing/portmaster/service/nameserver/nsutil" ) // ListMatch represents an entity that has been diff --git a/intel/customlists/config.go b/service/intel/customlists/config.go similarity index 100% rename from intel/customlists/config.go rename to service/intel/customlists/config.go diff --git a/intel/customlists/lists.go b/service/intel/customlists/lists.go similarity index 98% rename from intel/customlists/lists.go rename to service/intel/customlists/lists.go index c13a8cd5..33170dd7 100644 --- a/intel/customlists/lists.go +++ b/service/intel/customlists/lists.go @@ -12,7 +12,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/network/netutils" ) var ( diff --git a/intel/customlists/module.go b/service/intel/customlists/module.go similarity index 100% rename from intel/customlists/module.go rename to service/intel/customlists/module.go diff --git a/intel/entity.go b/service/intel/entity.go similarity index 98% rename from intel/entity.go rename to service/intel/entity.go index d89be9f6..df67edfc 100644 --- a/intel/entity.go +++ b/service/intel/entity.go @@ -2,18 +2,18 @@ package intel import ( "context" - "fmt" "net" "sort" + "strconv" "strings" "sync" "golang.org/x/net/publicsuffix" "github.com/safing/portbase/log" - "github.com/safing/portmaster/intel/filterlists" - "github.com/safing/portmaster/intel/geoip" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/intel/filterlists" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/service/network/netutils" ) // Entity describes a remote endpoint in many different ways. @@ -433,7 +433,7 @@ func (e *Entity) getASNLists(ctx context.Context) { } e.loadAsnListOnce.Do(func() { - asnStr := fmt.Sprintf("%d", asn) + asnStr := strconv.FormatUint(uint64(asn), 10) list, err := filterlists.LookupASNString(asnStr) if err != nil { log.Tracer(ctx).Errorf("intel: failed to get ASN blocklist for %d: %s", asn, err) diff --git a/intel/filterlists/bloom.go b/service/intel/filterlists/bloom.go similarity index 100% rename from intel/filterlists/bloom.go rename to service/intel/filterlists/bloom.go diff --git a/intel/filterlists/cache_version.go b/service/intel/filterlists/cache_version.go similarity index 100% rename from intel/filterlists/cache_version.go rename to service/intel/filterlists/cache_version.go diff --git a/intel/filterlists/database.go b/service/intel/filterlists/database.go similarity index 99% rename from intel/filterlists/database.go rename to service/intel/filterlists/database.go index 73330440..8b08f323 100644 --- a/intel/filterlists/database.go +++ b/service/intel/filterlists/database.go @@ -15,7 +15,7 @@ import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) const ( diff --git a/intel/filterlists/decoder.go b/service/intel/filterlists/decoder.go similarity index 94% rename from intel/filterlists/decoder.go rename to service/intel/filterlists/decoder.go index b8837aca..e66d8d84 100644 --- a/intel/filterlists/decoder.go +++ b/service/intel/filterlists/decoder.go @@ -103,18 +103,19 @@ func parseHeader(r io.Reader) (compressed bool, format byte, err error) { if _, err = r.Read(listHeader[:]); err != nil { // if we have an error here we can safely abort because // the file must be broken - return + return compressed, format, err } if listHeader[0] != dsd.LIST { err = fmt.Errorf("unexpected file type: %d (%c), expected dsd list", listHeader[0], listHeader[0]) - return + + return compressed, format, err } var compression [1]byte if _, err = r.Read(compression[:]); err != nil { // same here, a DSDL file must have at least 2 bytes header - return + return compressed, format, err } if compression[0] == dsd.GZIP { @@ -122,15 +123,16 @@ func parseHeader(r io.Reader) (compressed bool, format byte, err error) { var formatSlice [1]byte if _, err = r.Read(formatSlice[:]); err != nil { - return + return compressed, format, err } format = formatSlice[0] - return + return compressed, format, err } format = compression[0] - return // nolint:nakedret + + return compressed, format, err } // byteReader extends an io.Reader to implement the ByteReader interface. diff --git a/intel/filterlists/index.go b/service/intel/filterlists/index.go similarity index 99% rename from intel/filterlists/index.go rename to service/intel/filterlists/index.go index 095e3ebd..e5a593b6 100644 --- a/intel/filterlists/index.go +++ b/service/intel/filterlists/index.go @@ -12,7 +12,7 @@ import ( "github.com/safing/portbase/formats/dsd" "github.com/safing/portbase/log" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) // the following definitions are copied from the intelhub repository diff --git a/intel/filterlists/keys.go b/service/intel/filterlists/keys.go similarity index 100% rename from intel/filterlists/keys.go rename to service/intel/filterlists/keys.go diff --git a/intel/filterlists/lookup.go b/service/intel/filterlists/lookup.go similarity index 100% rename from intel/filterlists/lookup.go rename to service/intel/filterlists/lookup.go diff --git a/intel/filterlists/module.go b/service/intel/filterlists/module.go similarity index 96% rename from intel/filterlists/module.go rename to service/intel/filterlists/module.go index 6f5568aa..a7846ee4 100644 --- a/intel/filterlists/module.go +++ b/service/intel/filterlists/module.go @@ -8,8 +8,8 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/updates" ) var module *modules.Module diff --git a/intel/filterlists/module_test.go b/service/intel/filterlists/module_test.go similarity index 100% rename from intel/filterlists/module_test.go rename to service/intel/filterlists/module_test.go diff --git a/intel/filterlists/record.go b/service/intel/filterlists/record.go similarity index 100% rename from intel/filterlists/record.go rename to service/intel/filterlists/record.go diff --git a/intel/filterlists/updater.go b/service/intel/filterlists/updater.go similarity index 100% rename from intel/filterlists/updater.go rename to service/intel/filterlists/updater.go diff --git a/intel/geoip/country_info.go b/service/intel/geoip/country_info.go similarity index 100% rename from intel/geoip/country_info.go rename to service/intel/geoip/country_info.go diff --git a/intel/geoip/country_info_test.go b/service/intel/geoip/country_info_test.go similarity index 100% rename from intel/geoip/country_info_test.go rename to service/intel/geoip/country_info_test.go diff --git a/intel/geoip/database.go b/service/intel/geoip/database.go similarity index 98% rename from intel/geoip/database.go rename to service/intel/geoip/database.go index 61bde277..57b08578 100644 --- a/intel/geoip/database.go +++ b/service/intel/geoip/database.go @@ -10,7 +10,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) var worker *updateWorker diff --git a/intel/geoip/location.go b/service/intel/geoip/location.go similarity index 100% rename from intel/geoip/location.go rename to service/intel/geoip/location.go diff --git a/intel/geoip/location_test.go b/service/intel/geoip/location_test.go similarity index 100% rename from intel/geoip/location_test.go rename to service/intel/geoip/location_test.go diff --git a/intel/geoip/lookup.go b/service/intel/geoip/lookup.go similarity index 90% rename from intel/geoip/lookup.go rename to service/intel/geoip/lookup.go index da69fa26..0aeef434 100644 --- a/intel/geoip/lookup.go +++ b/service/intel/geoip/lookup.go @@ -1,7 +1,7 @@ package geoip import ( - "fmt" + "errors" "net" "github.com/oschwald/maxminddb-golang" @@ -16,7 +16,7 @@ func getReader(ip net.IP) *maxminddb.Reader { func GetLocation(ip net.IP) (*Location, error) { db := getReader(ip) if db == nil { - return nil, fmt.Errorf("geoip database not available") + return nil, errors.New("geoip database not available") } record := &Location{} if err := db.Lookup(ip, record); err != nil { diff --git a/intel/geoip/lookup_test.go b/service/intel/geoip/lookup_test.go similarity index 100% rename from intel/geoip/lookup_test.go rename to service/intel/geoip/lookup_test.go diff --git a/intel/geoip/module.go b/service/intel/geoip/module.go similarity index 94% rename from intel/geoip/module.go rename to service/intel/geoip/module.go index 0c65f1af..c5d44e00 100644 --- a/intel/geoip/module.go +++ b/service/intel/geoip/module.go @@ -5,7 +5,7 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) var module *modules.Module diff --git a/intel/geoip/module_test.go b/service/intel/geoip/module_test.go similarity index 64% rename from intel/geoip/module_test.go rename to service/intel/geoip/module_test.go index c1ae951b..c223d920 100644 --- a/intel/geoip/module_test.go +++ b/service/intel/geoip/module_test.go @@ -3,7 +3,7 @@ package geoip import ( "testing" - "github.com/safing/portmaster/core/pmtesting" + "github.com/safing/portmaster/service/core/pmtesting" ) func TestMain(m *testing.M) { diff --git a/intel/geoip/regions.go b/service/intel/geoip/regions.go similarity index 100% rename from intel/geoip/regions.go rename to service/intel/geoip/regions.go diff --git a/intel/geoip/regions_test.go b/service/intel/geoip/regions_test.go similarity index 100% rename from intel/geoip/regions_test.go rename to service/intel/geoip/regions_test.go diff --git a/intel/module.go b/service/intel/module.go similarity index 82% rename from intel/module.go rename to service/intel/module.go index ceec6b64..35c2d75c 100644 --- a/intel/module.go +++ b/service/intel/module.go @@ -2,7 +2,7 @@ package intel import ( "github.com/safing/portbase/modules" - _ "github.com/safing/portmaster/intel/customlists" + _ "github.com/safing/portmaster/service/intel/customlists" ) // Module of this package. Export needed for testing of the endpoints package. diff --git a/intel/resolver.go b/service/intel/resolver.go similarity index 100% rename from intel/resolver.go rename to service/intel/resolver.go diff --git a/nameserver/config.go b/service/nameserver/config.go similarity index 97% rename from nameserver/config.go rename to service/nameserver/config.go index c466a154..3e13044a 100644 --- a/nameserver/config.go +++ b/service/nameserver/config.go @@ -5,7 +5,7 @@ import ( "runtime" "github.com/safing/portbase/config" - "github.com/safing/portmaster/core" + "github.com/safing/portmaster/service/core" ) // CfgDefaultNameserverAddressKey is the config key for the listen address.. diff --git a/nameserver/conflict.go b/service/nameserver/conflict.go similarity index 94% rename from nameserver/conflict.go rename to service/nameserver/conflict.go index e02e1fd5..f716f7eb 100644 --- a/nameserver/conflict.go +++ b/service/nameserver/conflict.go @@ -7,8 +7,8 @@ import ( processInfo "github.com/shirou/gopsutil/process" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/state" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/state" ) var commonResolverIPs = []net.IP{ diff --git a/nameserver/failing.go b/service/nameserver/failing.go similarity index 97% rename from nameserver/failing.go rename to service/nameserver/failing.go index 1880dc96..2637a61f 100644 --- a/nameserver/failing.go +++ b/service/nameserver/failing.go @@ -4,8 +4,8 @@ import ( "sync" "time" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/resolver" ) type failingQuery struct { diff --git a/nameserver/metrics.go b/service/nameserver/metrics.go similarity index 100% rename from nameserver/metrics.go rename to service/nameserver/metrics.go diff --git a/nameserver/module.go b/service/nameserver/module.go similarity index 96% rename from nameserver/module.go rename to service/nameserver/module.go index ed7eb740..8380583b 100644 --- a/nameserver/module.go +++ b/service/nameserver/module.go @@ -14,9 +14,9 @@ import ( "github.com/safing/portbase/modules" "github.com/safing/portbase/modules/subsystems" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/compat" - "github.com/safing/portmaster/firewall" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/compat" + "github.com/safing/portmaster/service/firewall" + "github.com/safing/portmaster/service/netenv" ) var ( @@ -191,10 +191,8 @@ func handleListenError(err error, ip net.IP, port uint16, primaryListener bool) EventID: eventIDConflictingService + secondaryEventIDSuffix, Type: notifications.Error, Title: "Conflicting DNS Software", - Message: fmt.Sprintf( - "Restart Portmaster after you have deactivated or properly configured the conflicting software: %s", + Message: "Restart Portmaster after you have deactivated or properly configured the conflicting software: " + cfDescription, - ), ShowOnSystem: true, AvailableActions: []*notifications.Action{ { diff --git a/nameserver/nameserver.go b/service/nameserver/nameserver.go similarity index 93% rename from nameserver/nameserver.go rename to service/nameserver/nameserver.go index 5243f8c4..66bccd8e 100644 --- a/nameserver/nameserver.go +++ b/service/nameserver/nameserver.go @@ -11,16 +11,18 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/firewall" - "github.com/safing/portmaster/nameserver/nsutil" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/firewall" + "github.com/safing/portmaster/service/nameserver/nsutil" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/resolver" ) var hostname string +const internalError = "internal error: " + func handleRequestAsWorker(w dns.ResponseWriter, query *dns.Msg) { err := module.RunWorker("handle dns request", func(ctx context.Context) error { return handleRequest(ctx, w, query) @@ -130,7 +132,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) tracer.Tracef("nameserver: delaying failing lookup until end of fail duration for %s", remainingFailingDuration.Round(time.Millisecond)) time.Sleep(remainingFailingDuration) return reply(nsutil.ServerFailure( - "internal error: "+failingErr.Error(), + internalError+failingErr.Error(), "delayed failing query to mitigate request flooding", )) } @@ -138,7 +140,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) tracer.Tracef("nameserver: delaying failing lookup for %s", failingDelay.Round(time.Millisecond)) time.Sleep(failingDelay) return reply(nsutil.ServerFailure( - "internal error: "+failingErr.Error(), + internalError+failingErr.Error(), "delayed failing query to mitigate request flooding", fmt.Sprintf("error is cached for another %s", remainingFailingDuration.Round(time.Millisecond)), )) @@ -148,7 +150,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) local, err := netenv.IsMyIP(remoteAddr.IP) if err != nil { tracer.Warningf("nameserver: failed to check if request for %s is local: %s", q.ID(), err) - return reply(nsutil.ServerFailure("internal error: failed to check if request is local")) + return reply(nsutil.ServerFailure(internalError + " failed to check if request is local")) } // Create connection ID for dns request. @@ -170,7 +172,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) conn, err = network.NewConnectionFromExternalDNSRequest(ctx, q.FQDN, nil, connID, remoteAddr.IP) if err != nil { tracer.Warningf("nameserver: failed to get host/profile for request for %s%s: %s", q.FQDN, q.QType, err) - return reply(nsutil.ServerFailure("internal error: failed to get profile")) + return reply(nsutil.ServerFailure(internalError + "failed to get profile")) } default: @@ -199,7 +201,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) } } - switch conn.Verdict.Active { + switch conn.Verdict { // We immediately save blocked, dropped or failed verdicts so // they pop up in the UI. case network.VerdictBlock, network.VerdictDrop, network.VerdictFailed, network.VerdictRerouteToNameserver, network.VerdictRerouteToTunnel: @@ -210,7 +212,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) case network.VerdictUndecided, network.VerdictAccept: // Check if we have a response. if rrCache == nil { - conn.Failed("internal error: no reply", "") + conn.Failed(internalError+"no reply", "") return } @@ -245,7 +247,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) } // Check if there is a Verdict to act upon. - switch conn.Verdict.Active { //nolint:exhaustive // Only checking for specific values. + switch conn.Verdict { //nolint:exhaustive // Only checking for specific values. case network.VerdictBlock, network.VerdictDrop, network.VerdictFailed: tracer.Infof( "nameserver: returning %s response for %s to %s", @@ -293,7 +295,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) tracer.Warningf("nameserver: failed to resolve %s: %s", q.ID(), err) conn.Failed(fmt.Sprintf("query failed: %s", err), "") addFailingQuery(q, err) - return reply(nsutil.ServerFailure("internal error: " + err.Error())) + return reply(nsutil.ServerFailure(internalError + err.Error())) } } // Handle special cases. @@ -301,7 +303,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) case rrCache == nil: tracer.Warning("nameserver: received successful, but empty reply from resolver") addFailingQuery(q, errors.New("emptry reply from resolver")) - return reply(nsutil.ServerFailure("internal error: empty reply")) + return reply(nsutil.ServerFailure(internalError + "empty reply")) case rrCache.RCode == dns.RcodeNameError: // Try alternatives domain names for unofficial domain spaces. altRRCache := checkAlternativeCaches(ctx, q) @@ -325,7 +327,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg) } // Check if there is a Verdict to act upon. - switch conn.Verdict.Active { //nolint:exhaustive // Only checking for specific values. + switch conn.Verdict { //nolint:exhaustive // Only checking for specific values. case network.VerdictBlock, network.VerdictDrop, network.VerdictFailed: tracer.Infof( "nameserver: returning %s response for %s to %s", diff --git a/nameserver/nsutil/nsutil.go b/service/nameserver/nsutil/nsutil.go similarity index 100% rename from nameserver/nsutil/nsutil.go rename to service/nameserver/nsutil/nsutil.go diff --git a/nameserver/response.go b/service/nameserver/response.go similarity index 97% rename from nameserver/response.go rename to service/nameserver/response.go index 92dd80af..85daf140 100644 --- a/nameserver/response.go +++ b/service/nameserver/response.go @@ -7,7 +7,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/nameserver/nsutil" + "github.com/safing/portmaster/service/nameserver/nsutil" ) // sendResponse sends a response to query using w. The response message is diff --git a/netenv/addresses_test.go b/service/netenv/addresses_test.go similarity index 100% rename from netenv/addresses_test.go rename to service/netenv/addresses_test.go diff --git a/netenv/adresses.go b/service/netenv/adresses.go similarity index 98% rename from netenv/adresses.go rename to service/netenv/adresses.go index b050ad33..902dd0da 100644 --- a/netenv/adresses.go +++ b/service/netenv/adresses.go @@ -7,7 +7,7 @@ import ( "time" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/network/netutils" ) // GetAssignedAddresses returns the assigned IPv4 and IPv6 addresses of the host. diff --git a/netenv/api.go b/service/netenv/api.go similarity index 100% rename from netenv/api.go rename to service/netenv/api.go diff --git a/netenv/dbus_linux.go b/service/netenv/dbus_linux.go similarity index 100% rename from netenv/dbus_linux.go rename to service/netenv/dbus_linux.go diff --git a/netenv/dbus_linux_test.go b/service/netenv/dbus_linux_test.go similarity index 100% rename from netenv/dbus_linux_test.go rename to service/netenv/dbus_linux_test.go diff --git a/netenv/dialing.go b/service/netenv/dialing.go similarity index 100% rename from netenv/dialing.go rename to service/netenv/dialing.go diff --git a/netenv/environment.go b/service/netenv/environment.go similarity index 100% rename from netenv/environment.go rename to service/netenv/environment.go diff --git a/netenv/environment_default.go b/service/netenv/environment_default.go similarity index 100% rename from netenv/environment_default.go rename to service/netenv/environment_default.go diff --git a/netenv/environment_linux.go b/service/netenv/environment_linux.go similarity index 98% rename from netenv/environment_linux.go rename to service/netenv/environment_linux.go index d6b57b91..5f39875b 100644 --- a/netenv/environment_linux.go +++ b/service/netenv/environment_linux.go @@ -11,7 +11,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/network/netutils" ) var ( diff --git a/netenv/environment_linux_test.go b/service/netenv/environment_linux_test.go similarity index 100% rename from netenv/environment_linux_test.go rename to service/netenv/environment_linux_test.go diff --git a/netenv/environment_test.go b/service/netenv/environment_test.go similarity index 100% rename from netenv/environment_test.go rename to service/netenv/environment_test.go diff --git a/netenv/environment_windows.go b/service/netenv/environment_windows.go similarity index 100% rename from netenv/environment_windows.go rename to service/netenv/environment_windows.go diff --git a/netenv/environment_windows_test.go b/service/netenv/environment_windows_test.go similarity index 100% rename from netenv/environment_windows_test.go rename to service/netenv/environment_windows_test.go diff --git a/netenv/icmp_listener.go b/service/netenv/icmp_listener.go similarity index 98% rename from netenv/icmp_listener.go rename to service/netenv/icmp_listener.go index ca90b1e4..d1716d8a 100644 --- a/netenv/icmp_listener.go +++ b/service/netenv/icmp_listener.go @@ -7,7 +7,7 @@ import ( "github.com/tevino/abool" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) /* diff --git a/netenv/location.go b/service/netenv/location.go similarity index 98% rename from netenv/location.go rename to service/netenv/location.go index 23de17ff..276e33a3 100644 --- a/netenv/location.go +++ b/service/netenv/location.go @@ -14,9 +14,9 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/rng" - "github.com/safing/portmaster/intel/geoip" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" ) var ( diff --git a/netenv/location_default.go b/service/netenv/location_default.go similarity index 100% rename from netenv/location_default.go rename to service/netenv/location_default.go diff --git a/netenv/location_test.go b/service/netenv/location_test.go similarity index 100% rename from netenv/location_test.go rename to service/netenv/location_test.go diff --git a/netenv/location_windows.go b/service/netenv/location_windows.go similarity index 100% rename from netenv/location_windows.go rename to service/netenv/location_windows.go diff --git a/netenv/main.go b/service/netenv/main.go similarity index 100% rename from netenv/main.go rename to service/netenv/main.go diff --git a/netenv/main_test.go b/service/netenv/main_test.go similarity index 65% rename from netenv/main_test.go rename to service/netenv/main_test.go index 1ee7b730..64588b38 100644 --- a/netenv/main_test.go +++ b/service/netenv/main_test.go @@ -3,7 +3,7 @@ package netenv import ( "testing" - "github.com/safing/portmaster/core/pmtesting" + "github.com/safing/portmaster/service/core/pmtesting" ) func TestMain(m *testing.M) { diff --git a/netenv/network-change.go b/service/netenv/network-change.go similarity index 100% rename from netenv/network-change.go rename to service/netenv/network-change.go diff --git a/netenv/notes.md b/service/netenv/notes.md similarity index 100% rename from netenv/notes.md rename to service/netenv/notes.md diff --git a/netenv/online-status.go b/service/netenv/online-status.go similarity index 98% rename from netenv/online-status.go rename to service/netenv/online-status.go index 8be03a59..fac5e170 100644 --- a/netenv/online-status.go +++ b/service/netenv/online-status.go @@ -15,8 +15,8 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/updates" ) // OnlineStatus represent a state of connectivity to the Internet. @@ -77,6 +77,7 @@ var ( "network-test.debian.org.", // Debian "204.pop-os.org.", // Pop OS "conncheck.opensuse.org.", // OpenSUSE + "ping.archlinux.org", // Arch // There are probably a lot more domains for all the Linux Distro/DE Variants. Please raise issues and/or submit PRs! // https://github.com/solus-project/budgie-desktop/issues/807 // https://www.lguruprasad.in/blog/2015/07/21/enabling-captive-portal-detection-in-gnome-3-14-on-debian-jessie/ @@ -434,7 +435,7 @@ func checkOnlineStatus(ctx context.Context) { ipv4, ipv6, err := GetAssignedAddresses() if err != nil { - log.Warningf("network: failed to get assigned network addresses: %s", err) + log.Warningf("netenv: failed to get assigned network addresses: %s", err) } else { var lan bool diff --git a/netenv/online-status_test.go b/service/netenv/online-status_test.go similarity index 100% rename from netenv/online-status_test.go rename to service/netenv/online-status_test.go diff --git a/netenv/os_android.go b/service/netenv/os_android.go similarity index 92% rename from netenv/os_android.go rename to service/netenv/os_android.go index 84c36958..aceed896 100644 --- a/netenv/os_android.go +++ b/service/netenv/os_android.go @@ -1,9 +1,10 @@ package netenv import ( - "github.com/safing/portmaster-android/go/app_interface" "net" "time" + + "github.com/safing/portmaster/service-android/go/app_interface" ) var ( diff --git a/netenv/os_default.go b/service/netenv/os_default.go similarity index 100% rename from netenv/os_default.go rename to service/netenv/os_default.go diff --git a/netquery/active_chart_handler.go b/service/netquery/active_chart_handler.go similarity index 94% rename from netquery/active_chart_handler.go rename to service/netquery/active_chart_handler.go index 08628394..264c903e 100644 --- a/netquery/active_chart_handler.go +++ b/service/netquery/active_chart_handler.go @@ -10,7 +10,7 @@ import ( "net/http" "strings" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) // ActiveChartHandler handles requests for connection charts. @@ -42,7 +42,7 @@ func (ch *ActiveChartHandler) ServeHTTP(resp http.ResponseWriter, req *http.Requ orm.WithResult(&result), orm.WithSchema(*ch.Database.Schema), ); err != nil { - http.Error(resp, "Failed to execute query: "+err.Error(), http.StatusInternalServerError) + http.Error(resp, failedQuery+err.Error(), http.StatusInternalServerError) return } @@ -77,7 +77,7 @@ func (ch *ActiveChartHandler) parseRequest(req *http.Request) (*QueryActiveConne var requestPayload QueryActiveConnectionChartPayload blob, err := io.ReadAll(body) if err != nil { - return nil, fmt.Errorf("failed to read body" + err.Error()) + return nil, fmt.Errorf("failed to read body: %w", err) } body = bytes.NewReader(blob) diff --git a/netquery/bandwidth_chart_handler.go b/service/netquery/bandwidth_chart_handler.go similarity index 94% rename from netquery/bandwidth_chart_handler.go rename to service/netquery/bandwidth_chart_handler.go index 5bb5b526..8e5647c4 100644 --- a/netquery/bandwidth_chart_handler.go +++ b/service/netquery/bandwidth_chart_handler.go @@ -10,7 +10,7 @@ import ( "net/http" "strings" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) // BandwidthChartHandler handles requests for connection charts. @@ -49,7 +49,7 @@ func (ch *BandwidthChartHandler) ServeHTTP(resp http.ResponseWriter, req *http.R orm.WithResult(&result), orm.WithSchema(*ch.Database.Schema), ); err != nil { - http.Error(resp, "Failed to execute query: "+err.Error(), http.StatusInternalServerError) + http.Error(resp, failedQuery+err.Error(), http.StatusInternalServerError) return } @@ -84,7 +84,7 @@ func (ch *BandwidthChartHandler) parseRequest(req *http.Request) (*BandwidthChar var requestPayload BandwidthChartRequest blob, err := io.ReadAll(body) if err != nil { - return nil, fmt.Errorf("failed to read body" + err.Error()) + return nil, fmt.Errorf("failed to read body: %w", err) } body = bytes.NewReader(blob) diff --git a/netquery/database.go b/service/netquery/database.go similarity index 98% rename from netquery/database.go rename to service/netquery/database.go index e3287345..cb9f4039 100644 --- a/netquery/database.go +++ b/service/netquery/database.go @@ -19,11 +19,11 @@ import ( "github.com/safing/portbase/config" "github.com/safing/portbase/dataroot" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netquery/orm" - "github.com/safing/portmaster/network" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/netquery/orm" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/profile" ) // InMemory is the "file path" to open a new in-memory database. diff --git a/netquery/manager.go b/service/netquery/manager.go similarity index 91% rename from netquery/manager.go rename to service/netquery/manager.go index 810782a8..d1809116 100644 --- a/netquery/manager.go +++ b/service/netquery/manager.go @@ -10,7 +10,7 @@ import ( "github.com/safing/portbase/formats/dsd" "github.com/safing/portbase/log" "github.com/safing/portbase/runtime" - "github.com/safing/portmaster/network" + "github.com/safing/portmaster/service/network" ) type ( @@ -23,18 +23,18 @@ type ( // insert or an update. // The ID of Conn is unique and can be trusted to never collide with other // connections of the save device. - Save(context.Context, Conn, bool) error + Save(ctx context.Context, conn Conn, history bool) error // MarkAllHistoryConnectionsEnded marks all active connections in the history // database as ended NOW. - MarkAllHistoryConnectionsEnded(context.Context) error + MarkAllHistoryConnectionsEnded(ctx context.Context) error // RemoveAllHistoryData removes all connections from the history database. - RemoveAllHistoryData(context.Context) error + RemoveAllHistoryData(ctx context.Context) error // RemoveHistoryForProfile removes all connections from the history database. // for a given profile ID (source/id) - RemoveHistoryForProfile(context.Context, string) error + RemoveHistoryForProfile(ctx context.Context, profile string) error // UpdateBandwidth updates bandwidth data for the connection and optionally also writes // the bandwidth data to the history database. @@ -183,9 +183,7 @@ func convertConnection(conn *network.Connection) (*Conn, error) { IPProtocol: conn.IPProtocol, LocalIP: conn.LocalIP.String(), LocalPort: conn.LocalPort, - FirewallVerdict: conn.Verdict.Firewall, - ActiveVerdict: conn.Verdict.Active, - WorstVerdict: conn.Verdict.Worst, + ActiveVerdict: conn.Verdict, Started: time.Unix(conn.Started, 0), Tunneled: conn.Tunneled, Encrypted: conn.Encrypted, @@ -207,16 +205,7 @@ func convertConnection(conn *network.Connection) (*Conn, error) { c.Type = "" } - switch conn.Verdict.Firewall { - case network.VerdictAccept, network.VerdictRerouteToNameserver, network.VerdictRerouteToTunnel: - accepted := true - c.Allowed = &accepted - case network.VerdictBlock, network.VerdictDrop: - allowed := false - c.Allowed = &allowed - case network.VerdictUndecided, network.VerdictUndeterminable, network.VerdictFailed: - c.Allowed = nil - } + c.Allowed = &conn.ConnectionEstablished if conn.Ended > 0 { ended := time.Unix(conn.Ended, 0) diff --git a/netquery/module_api.go b/service/netquery/module_api.go similarity index 99% rename from netquery/module_api.go rename to service/netquery/module_api.go index b4e56b02..00950a01 100644 --- a/netquery/module_api.go +++ b/service/netquery/module_api.go @@ -17,7 +17,7 @@ import ( "github.com/safing/portbase/modules" "github.com/safing/portbase/modules/subsystems" "github.com/safing/portbase/runtime" - "github.com/safing/portmaster/network" + "github.com/safing/portmaster/service/network" ) // DefaultModule is the default netquery module. diff --git a/netquery/orm/decoder.go b/service/netquery/orm/decoder.go similarity index 97% rename from netquery/orm/decoder.go rename to service/netquery/orm/decoder.go index 21ce6146..169c5e7e 100644 --- a/netquery/orm/decoder.go +++ b/service/netquery/orm/decoder.go @@ -41,13 +41,13 @@ type ( // by *sqlite.Stmt. Stmt interface { ColumnCount() int - ColumnName(int) string - ColumnType(int) sqlite.ColumnType - ColumnText(int) string - ColumnBool(int) bool - ColumnFloat(int) float64 - ColumnInt(int) int - ColumnReader(int) *bytes.Reader + ColumnName(col int) string + ColumnType(col int) sqlite.ColumnType + ColumnText(col int) string + ColumnBool(col int) bool + ColumnFloat(col int) float64 + ColumnInt(col int) int + ColumnReader(col int) *bytes.Reader } // DecodeFunc is called for each non-basic type during decoding. @@ -230,7 +230,7 @@ func DatetimeDecoder(loc *time.Location) DecodeFunc { case sqlite.TypeFloat: // stored as Julian day numbers - return nil, false, fmt.Errorf("REAL storage type not support for time.Time") + return nil, false, errors.New("REAL storage type not support for time.Time") case sqlite.TypeNull: return nil, true, nil @@ -359,7 +359,7 @@ func decodeBasic() DecodeFunc { case reflect.Slice: if outval.Type().Elem().Kind() != reflect.Uint8 { - return nil, false, fmt.Errorf("slices other than []byte for BLOB are not supported") + return nil, false, errors.New("slices other than []byte for BLOB are not supported") } if colType != sqlite.TypeBlob { diff --git a/netquery/orm/decoder_test.go b/service/netquery/orm/decoder_test.go similarity index 100% rename from netquery/orm/decoder_test.go rename to service/netquery/orm/decoder_test.go diff --git a/netquery/orm/encoder.go b/service/netquery/orm/encoder.go similarity index 98% rename from netquery/orm/encoder.go rename to service/netquery/orm/encoder.go index 6dcd0e68..8aa53387 100644 --- a/netquery/orm/encoder.go +++ b/service/netquery/orm/encoder.go @@ -2,6 +2,7 @@ package orm import ( "context" + "errors" "fmt" "reflect" "time" @@ -171,7 +172,7 @@ func DatetimeEncoder(loc *time.Location) EncodeFunc { valInterface := val.Interface() t, ok = valInterface.(time.Time) if !ok { - return nil, false, fmt.Errorf("cannot convert reflect value to time.Time") + return nil, false, errors.New("cannot convert reflect value to time.Time") } case valType.Kind() == reflect.String && colDef.IsTime: diff --git a/netquery/orm/encoder_test.go b/service/netquery/orm/encoder_test.go similarity index 97% rename from netquery/orm/encoder_test.go rename to service/netquery/orm/encoder_test.go index d0d3c039..3a1dbb5b 100644 --- a/netquery/orm/encoder_test.go +++ b/service/netquery/orm/encoder_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "zombiezen.com/go/sqlite" ) @@ -120,7 +121,7 @@ func TestEncodeAsMap(t *testing.T) { //nolint:tparallel c := cases[idx] t.Run(c.Desc, func(t *testing.T) { res, err := ToParamMap(ctx, c.Input, "", DefaultEncodeConfig, nil) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, c.Expected, res) }) } @@ -253,7 +254,7 @@ func TestEncodeValue(t *testing.T) { //nolint:tparallel c := cases[idx] t.Run(c.Desc, func(t *testing.T) { res, err := EncodeValue(ctx, &c.Column, c.Input, DefaultEncodeConfig) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, c.Output, res) }) } diff --git a/netquery/orm/query_runner.go b/service/netquery/orm/query_runner.go similarity index 100% rename from netquery/orm/query_runner.go rename to service/netquery/orm/query_runner.go diff --git a/netquery/orm/schema_builder.go b/service/netquery/orm/schema_builder.go similarity index 99% rename from netquery/orm/schema_builder.go rename to service/netquery/orm/schema_builder.go index 018a55e1..893dab2e 100644 --- a/netquery/orm/schema_builder.go +++ b/service/netquery/orm/schema_builder.go @@ -274,7 +274,7 @@ func applyStructFieldTag(fieldType reflect.StructField, def *ColumnDef) error { case sqlite.TypeText: def.Default = defaultValue case sqlite.TypeBlob: - return fmt.Errorf("default values for TypeBlob not yet supported") + return errors.New("default values for TypeBlob not yet supported") default: return fmt.Errorf("failed to apply default value for unknown sqlite column type %s", def.Type) } diff --git a/netquery/orm/schema_builder_test.go b/service/netquery/orm/schema_builder_test.go similarity index 93% rename from netquery/orm/schema_builder_test.go rename to service/netquery/orm/schema_builder_test.go index fdd43ec7..b39fac72 100644 --- a/netquery/orm/schema_builder_test.go +++ b/service/netquery/orm/schema_builder_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSchemaBuilder(t *testing.T) { @@ -37,7 +38,7 @@ func TestSchemaBuilder(t *testing.T) { c := cases[idx] res, err := GenerateTableSchema(c.Name, c.Model) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, c.ExpectedSQL, res.CreateStatement("main", false)) } } diff --git a/netquery/query.go b/service/netquery/query.go similarity index 99% rename from netquery/query.go rename to service/netquery/query.go index 2b81bfb1..cb84ac30 100644 --- a/netquery/query.go +++ b/service/netquery/query.go @@ -13,7 +13,7 @@ import ( "golang.org/x/exp/slices" "zombiezen.com/go/sqlite" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) // DatabaseName is a database name constant. diff --git a/netquery/query_handler.go b/service/netquery/query_handler.go similarity index 96% rename from netquery/query_handler.go rename to service/netquery/query_handler.go index 8e704d3e..e996c183 100644 --- a/netquery/query_handler.go +++ b/service/netquery/query_handler.go @@ -14,11 +14,13 @@ import ( servertiming "github.com/mitchellh/go-server-timing" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) var charOnlyRegexp = regexp.MustCompile("[a-zA-Z]+") +const failedQuery = "Failed to execute query: " + type ( // QueryHandler implements http.Handler and allows to perform SQL @@ -78,7 +80,7 @@ func (qh *QueryHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { orm.WithResult(&result), orm.WithSchema(*qh.Database.Schema), ); err != nil { - http.Error(resp, "Failed to execute query: "+err.Error(), http.StatusInternalServerError) + http.Error(resp, failedQuery+err.Error(), http.StatusInternalServerError) return } @@ -230,7 +232,7 @@ func parseQueryRequestPayload[T any](req *http.Request) (*T, error) { //nolint:d blob, err := io.ReadAll(body) if err != nil { - return nil, fmt.Errorf("failed to read body" + err.Error()) + return nil, fmt.Errorf("failed to read body: %w", err) } body = bytes.NewReader(blob) diff --git a/netquery/query_request.go b/service/netquery/query_request.go similarity index 99% rename from netquery/query_request.go rename to service/netquery/query_request.go index ea5162a9..97fc5789 100644 --- a/netquery/query_request.go +++ b/service/netquery/query_request.go @@ -7,7 +7,7 @@ import ( "golang.org/x/exp/slices" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) type ( diff --git a/netquery/query_test.go b/service/netquery/query_test.go similarity index 97% rename from netquery/query_test.go rename to service/netquery/query_test.go index afd65b4f..0582aacf 100644 --- a/netquery/query_test.go +++ b/service/netquery/query_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) func TestUnmarshalQuery(t *testing.T) { //nolint:tparallel @@ -102,7 +102,7 @@ func TestUnmarshalQuery(t *testing.T) { //nolint:tparallel assert.Equal(t, c.Error.Error(), err.Error()) } } else { - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, c.Expected, q) } }) @@ -241,7 +241,7 @@ func TestQueryBuilder(t *testing.T) { //nolint:tparallel assert.Equal(t, c.E.Error(), err.Error(), "test case %d", cID) } } else { - assert.NoError(t, err, "test case %d", cID) + require.NoError(t, err, "test case %d", cID) assert.Equal(t, c.P, params, "test case %d", cID) assert.Equal(t, c.R, str, "test case %d", cID) } diff --git a/netquery/runtime_query_runner.go b/service/netquery/runtime_query_runner.go similarity index 97% rename from netquery/runtime_query_runner.go rename to service/netquery/runtime_query_runner.go index 3b443ec5..67ba449b 100644 --- a/netquery/runtime_query_runner.go +++ b/service/netquery/runtime_query_runner.go @@ -10,7 +10,7 @@ import ( "github.com/safing/portbase/formats/dsd" "github.com/safing/portbase/log" "github.com/safing/portbase/runtime" - "github.com/safing/portmaster/netquery/orm" + "github.com/safing/portmaster/service/netquery/orm" ) // RuntimeQueryRunner provides a simple interface for the runtime database diff --git a/network/api.go b/service/network/api.go similarity index 94% rename from network/api.go rename to service/network/api.go index 4635b49a..878db313 100644 --- a/network/api.go +++ b/service/network/api.go @@ -12,11 +12,11 @@ import ( "github.com/safing/portbase/config" "github.com/safing/portbase/database/query" "github.com/safing/portbase/utils/debug" - "github.com/safing/portmaster/network/state" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/resolver" - "github.com/safing/portmaster/status" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/network/state" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/resolver" + "github.com/safing/portmaster/service/status" + "github.com/safing/portmaster/service/updates" ) func registerAPIEndpoints() error { @@ -136,11 +136,11 @@ func AddNetworkDebugData(di *debug.Info, profile, where string) { // Collect matching connections. var ( //nolint:prealloc // We don't know the size. - debugConns []*Connection - accepted int - total int - transitioning int + debugConns []*Connection + accepted int + total int ) + for maybeConn := range it.Next { // Switch to correct type. conn, ok := maybeConn.(*Connection) @@ -169,15 +169,13 @@ func AddNetworkDebugData(di *debug.Info, profile, where string) { // Count. total++ - switch conn.Verdict.Firewall { //nolint:exhaustive + switch conn.Verdict { //nolint:exhaustive case VerdictAccept, VerdictRerouteToNameserver, VerdictRerouteToTunnel: + accepted++ } - if conn.Verdict.Active != conn.Verdict.Firewall { - transitioning++ - } // Add to list. debugConns = append(debugConns, conn) @@ -186,10 +184,9 @@ func AddNetworkDebugData(di *debug.Info, profile, where string) { // Add it all. di.AddSection( fmt.Sprintf( - "Network: %d/%d [~%d] Connections", + "Network: %d/%d Connections", accepted, total, - transitioning, ), debug.UseCodeSection|debug.AddContentLineBreaks, buildNetworkDebugInfoData(debugConns), diff --git a/network/api_test.go b/service/network/api_test.go similarity index 88% rename from network/api_test.go rename to service/network/api_test.go index 0e908c8c..c44109b0 100644 --- a/network/api_test.go +++ b/service/network/api_test.go @@ -5,7 +5,7 @@ import ( "net" "testing" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) func TestDebugInfoLineFormatting(t *testing.T) { @@ -40,15 +40,7 @@ var connectionTestData = []*Connection{ Country: "", ASN: 0, }, - Verdict: struct { - Worst Verdict - Active Verdict - Firewall Verdict - }{ - Worst: 2, - Active: 2, - Firewall: 2, - }, + Verdict: 2, Reason: Reason{ Msg: "incoming connection blocked by default", OptionKey: "filter/serviceEndpoints", @@ -88,15 +80,7 @@ var connectionTestData = []*Connection{ Country: "DE", ASN: 16509, }, - Verdict: struct { - Worst Verdict - Active Verdict - Firewall Verdict - }{ - Worst: 2, - Active: 2, - Firewall: 2, - }, + Verdict: 2, Reason: Reason{ Msg: "default permit", OptionKey: "filter/defaultAction", @@ -139,15 +123,7 @@ var connectionTestData = []*Connection{ Country: "US", ASN: 15169, }, - Verdict: struct { - Worst Verdict - Active Verdict - Firewall Verdict - }{ - Worst: 2, - Active: 2, - Firewall: 2, - }, + Verdict: 2, Reason: Reason{ Msg: "default permit", OptionKey: "filter/defaultAction", diff --git a/network/clean.go b/service/network/clean.go similarity index 95% rename from network/clean.go rename to service/network/clean.go index b15fbaa0..9901b00b 100644 --- a/network/clean.go +++ b/service/network/clean.go @@ -5,9 +5,9 @@ import ( "time" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/state" - "github.com/safing/portmaster/process" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/state" + "github.com/safing/portmaster/service/process" ) const ( diff --git a/network/connection.go b/service/network/connection.go similarity index 96% rename from network/connection.go rename to service/network/connection.go index 5c4c687a..c95119de 100644 --- a/network/connection.go +++ b/service/network/connection.go @@ -13,16 +13,16 @@ import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" "github.com/safing/portbase/notifications" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" - _ "github.com/safing/portmaster/process/tags" - "github.com/safing/portmaster/resolver" - "github.com/safing/spn/access" - "github.com/safing/spn/access/account" - "github.com/safing/spn/navigator" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" + _ "github.com/safing/portmaster/service/process/tags" + "github.com/safing/portmaster/service/resolver" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/access/account" + "github.com/safing/portmaster/spn/navigator" ) // FirewallHandler defines the function signature for a firewall @@ -121,17 +121,9 @@ type Connection struct { //nolint:maligned // TODO: fix alignment // Verdict holds the decisions that are made for a connection // The verdict may change so any access to it must be guarded by the // connection lock. - Verdict struct { - // Worst verdict holds the worst verdict that was assigned to this - // connection from a privacy/security perspective. - Worst Verdict - // Active verdict holds the verdict that Portmaster will respond with. - // This is different from the Firewall verdict in order to guarantee proper - // transition between verdicts that need the connection to be re-established. - Active Verdict - // Firewall holds the last (most recent) decision by the firewall. - Firewall Verdict - } + Verdict Verdict + // Whether or not the connection has been established at least once. + ConnectionEstablished bool // Reason holds information justifying the verdict, as well as additional // information about the reason. // Access to Reason must be guarded by the connection lock. @@ -723,22 +715,15 @@ func (conn *Connection) SetVerdict(newVerdict Verdict, reason, reasonOptionKey s return true // TODO: remove } -// SetVerdictDirectly sets the firewall verdict. +// SetVerdictDirectly sets the verdict. func (conn *Connection) SetVerdictDirectly(newVerdict Verdict) { - conn.Verdict.Firewall = newVerdict + conn.Verdict = newVerdict } // VerdictVerb returns the verdict as a verb, while taking any special states // into account. func (conn *Connection) VerdictVerb() string { - if conn.Verdict.Firewall == conn.Verdict.Active { - return conn.Verdict.Firewall.Verb() - } - return fmt.Sprintf( - "%s (transitioning to %s)", - conn.Verdict.Active.Verb(), - conn.Verdict.Firewall.Verb(), - ) + return conn.Verdict.Verb() } // DataIsComplete returns whether all information about the connection is @@ -767,6 +752,16 @@ func (conn *Connection) SaveWhenFinished() { func (conn *Connection) Save() { conn.UpdateMeta() + // nolint:exhaustive + switch conn.Verdict { + case VerdictAccept, VerdictRerouteToNameserver: + conn.ConnectionEstablished = true + case VerdictRerouteToTunnel: + // this is already handled when the connection tunnel has been + // established. + default: + } + // Do not save/update until data is complete. if !conn.DataIsComplete() { return @@ -1004,7 +999,7 @@ func packetHandlerHandleConn(ctx context.Context, conn *Connection, pkt packet.P switch { case conn.DataIsComplete(): tracer.Infof("filter: connection %s %s: %s", conn, conn.VerdictVerb(), conn.Reason.Msg) - case conn.Verdict.Firewall != VerdictUndecided: + case conn.Verdict != VerdictUndecided: tracer.Debugf("filter: connection %s fast-tracked", pkt) default: tracer.Debugf("filter: gathered data on connection %s", conn) diff --git a/network/connection_android.go b/service/network/connection_android.go similarity index 88% rename from network/connection_android.go rename to service/network/connection_android.go index 71b16ed4..bbd49864 100644 --- a/network/connection_android.go +++ b/service/network/connection_android.go @@ -6,11 +6,11 @@ import ( "net" "time" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" - "github.com/safing/spn/navigator" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/spn/navigator" "github.com/tevino/abool" ) diff --git a/network/connection_store.go b/service/network/connection_store.go similarity index 100% rename from network/connection_store.go rename to service/network/connection_store.go diff --git a/network/database.go b/service/network/database.go similarity index 98% rename from network/database.go rename to service/network/database.go index 457b2693..9b098d48 100644 --- a/network/database.go +++ b/service/network/database.go @@ -11,7 +11,7 @@ import ( "github.com/safing/portbase/database/query" "github.com/safing/portbase/database/record" "github.com/safing/portbase/database/storage" - "github.com/safing/portmaster/process" + "github.com/safing/portmaster/service/process" ) const ( diff --git a/network/dns.go b/service/network/dns.go similarity index 96% rename from network/dns.go rename to service/network/dns.go index 4f481b32..201dd25b 100644 --- a/network/dns.go +++ b/service/network/dns.go @@ -11,10 +11,10 @@ import ( "golang.org/x/exp/slices" "github.com/safing/portbase/log" - "github.com/safing/portmaster/nameserver/nsutil" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/resolver" + "github.com/safing/portmaster/service/nameserver/nsutil" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/resolver" ) var ( @@ -208,7 +208,7 @@ func writeOpenDNSRequestsToDB() { // ReplyWithDNS creates a new reply to the given request with the data from the RRCache, and additional informational records. func (conn *Connection) ReplyWithDNS(ctx context.Context, request *dns.Msg) *dns.Msg { // Select request responder. - switch conn.Verdict.Active { + switch conn.Verdict { case VerdictBlock: return nsutil.BlockIP().ReplyWithDNS(ctx, request) case VerdictDrop: @@ -229,7 +229,7 @@ func (conn *Connection) ReplyWithDNS(ctx context.Context, request *dns.Msg) *dns func (conn *Connection) GetExtraRRs(ctx context.Context, request *dns.Msg) []dns.RR { // Select level to add the verdict record with. var level log.Severity - switch conn.Verdict.Active { + switch conn.Verdict { case VerdictFailed: level = log.ErrorLevel case VerdictUndecided, VerdictUndeterminable, diff --git a/network/iphelper/get.go b/service/network/iphelper/get.go similarity index 96% rename from network/iphelper/get.go rename to service/network/iphelper/get.go index 31f1c925..e78c70fc 100644 --- a/network/iphelper/get.go +++ b/service/network/iphelper/get.go @@ -5,7 +5,7 @@ package iphelper import ( "sync" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/socket" ) var ( diff --git a/network/iphelper/iphelper.go b/service/network/iphelper/iphelper.go similarity index 100% rename from network/iphelper/iphelper.go rename to service/network/iphelper/iphelper.go diff --git a/network/iphelper/tables.go b/service/network/iphelper/tables.go similarity index 99% rename from network/iphelper/tables.go rename to service/network/iphelper/tables.go index 94998d7e..b221d2ef 100644 --- a/network/iphelper/tables.go +++ b/service/network/iphelper/tables.go @@ -11,7 +11,7 @@ import ( "unsafe" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/socket" "golang.org/x/sys/windows" ) @@ -214,7 +214,7 @@ func (ipHelper *IPHelper) getTable(ipVersion, protocol uint8) (connections []*so maxTries := 5 usedBufSize := lbf.getBufSize() var buf []byte - +triesLoop: for i := 1; i <= maxTries; i++ { bufSizeParam := usedBufSize buf = make([]byte, bufSizeParam) @@ -256,7 +256,7 @@ func (ipHelper *IPHelper) getTable(ipVersion, protocol uint8) (connections []*so return nil, nil, fmt.Errorf("invalid parameter: [NT 0x%X] %s", r1, err) case windows.NO_ERROR: // success - break + break triesLoop default: return nil, nil, fmt.Errorf("unexpected error: [NT 0x%X] %s", r1, err) } diff --git a/network/iphelper/tables_test.go b/service/network/iphelper/tables_test.go similarity index 100% rename from network/iphelper/tables_test.go rename to service/network/iphelper/tables_test.go diff --git a/network/metrics.go b/service/network/metrics.go similarity index 97% rename from network/metrics.go rename to service/network/metrics.go index 7101e921..5ffa1880 100644 --- a/network/metrics.go +++ b/service/network/metrics.go @@ -4,7 +4,7 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/config" "github.com/safing/portbase/metrics" - "github.com/safing/portmaster/process" + "github.com/safing/portmaster/service/process" ) var ( @@ -140,7 +140,7 @@ func (conn *Connection) addToMetrics() { } // Check the verdict. - switch conn.Verdict.Firewall { //nolint:exhaustive // Not critical. + switch conn.Verdict { //nolint:exhaustive // Not critical. case VerdictBlock, VerdictDrop: blockedOutConnCounter.Inc() conn.addedToMetrics = true diff --git a/network/module.go b/service/network/module.go similarity index 96% rename from network/module.go rename to service/network/module.go index 1a7fe891..bebcb467 100644 --- a/network/module.go +++ b/service/network/module.go @@ -8,9 +8,9 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/state" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/state" + "github.com/safing/portmaster/service/profile" ) var ( diff --git a/network/multicast.go b/service/network/multicast.go similarity index 96% rename from network/multicast.go rename to service/network/multicast.go index d7c8f9a7..d12809a9 100644 --- a/network/multicast.go +++ b/service/network/multicast.go @@ -3,7 +3,7 @@ package network import ( "net" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/network/netutils" ) // GetMulticastRequestConn searches for and returns the requesting connnection diff --git a/network/netutils/address.go b/service/network/netutils/address.go similarity index 96% rename from network/netutils/address.go rename to service/network/netutils/address.go index 3d89c39c..44337392 100644 --- a/network/netutils/address.go +++ b/service/network/netutils/address.go @@ -5,7 +5,7 @@ import ( "net" "strconv" - "github.com/safing/portmaster/network/packet" + "github.com/safing/portmaster/service/network/packet" ) var errInvalidIP = errors.New("invalid IP address") diff --git a/network/netutils/dns.go b/service/network/netutils/dns.go similarity index 100% rename from network/netutils/dns.go rename to service/network/netutils/dns.go diff --git a/network/netutils/dns_test.go b/service/network/netutils/dns_test.go similarity index 100% rename from network/netutils/dns_test.go rename to service/network/netutils/dns_test.go diff --git a/network/netutils/ip.go b/service/network/netutils/ip.go similarity index 100% rename from network/netutils/ip.go rename to service/network/netutils/ip.go diff --git a/network/netutils/ip_test.go b/service/network/netutils/ip_test.go similarity index 100% rename from network/netutils/ip_test.go rename to service/network/netutils/ip_test.go diff --git a/network/netutils/tcpassembly.go b/service/network/netutils/tcpassembly.go similarity index 100% rename from network/netutils/tcpassembly.go rename to service/network/netutils/tcpassembly.go diff --git a/network/packet/bandwidth.go b/service/network/packet/bandwidth.go similarity index 100% rename from network/packet/bandwidth.go rename to service/network/packet/bandwidth.go diff --git a/network/packet/const.go b/service/network/packet/const.go similarity index 100% rename from network/packet/const.go rename to service/network/packet/const.go diff --git a/network/packet/info_only.go b/service/network/packet/info_only.go similarity index 100% rename from network/packet/info_only.go rename to service/network/packet/info_only.go diff --git a/network/packet/packet.go b/service/network/packet/packet.go similarity index 94% rename from network/packet/packet.go rename to service/network/packet/packet.go index 1ac3047f..18aa7eb2 100644 --- a/network/packet/packet.go +++ b/service/network/packet/packet.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "strconv" "github.com/google/gopacket" ) @@ -207,9 +208,9 @@ func (pkt *Base) FmtRemoteIP() string { func (pkt *Base) FmtRemotePort() string { if pkt.info.SrcPort != 0 { if pkt.info.Inbound { - return fmt.Sprintf("%d", pkt.info.SrcPort) + return strconv.FormatUint(uint64(pkt.info.SrcPort), 10) } - return fmt.Sprintf("%d", pkt.info.DstPort) + return strconv.FormatUint(uint64(pkt.info.DstPort), 10) } return "-" } @@ -235,10 +236,10 @@ type Packet interface { ExpectInfo() bool // Info. - SetCtx(context.Context) + SetCtx(ctx context.Context) Ctx() context.Context Info() *Info - SetPacketInfo(Info) + SetPacketInfo(info Info) IsInbound() bool IsOutbound() bool SetInbound() @@ -253,8 +254,8 @@ type Packet interface { Payload() []byte // Matching. - MatchesAddress(bool, IPProtocol, *net.IPNet, uint16) bool - MatchesIP(bool, *net.IPNet) bool + MatchesAddress(remote bool, protocol IPProtocol, network *net.IPNet, port uint16) bool + MatchesIP(endpoint bool, network *net.IPNet) bool // Formatting. String() string diff --git a/network/packet/packetinfo.go b/service/network/packet/packetinfo.go similarity index 100% rename from network/packet/packetinfo.go rename to service/network/packet/packetinfo.go diff --git a/network/packet/parse.go b/service/network/packet/parse.go similarity index 100% rename from network/packet/parse.go rename to service/network/packet/parse.go diff --git a/network/ports.go b/service/network/ports.go similarity index 100% rename from network/ports.go rename to service/network/ports.go diff --git a/network/proc/findpid.go b/service/network/proc/findpid.go similarity index 97% rename from network/proc/findpid.go rename to service/network/proc/findpid.go index 2fbb7130..e5cd5185 100644 --- a/network/proc/findpid.go +++ b/service/network/proc/findpid.go @@ -9,7 +9,7 @@ import ( "strconv" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/socket" ) // GetPID returns the already existing pid of the given socket info or searches for it. diff --git a/network/proc/pids_by_user.go b/service/network/proc/pids_by_user.go similarity index 100% rename from network/proc/pids_by_user.go rename to service/network/proc/pids_by_user.go diff --git a/network/proc/tables.go b/service/network/proc/tables.go similarity index 99% rename from network/proc/tables.go rename to service/network/proc/tables.go index 62c4a4c5..2569a7f0 100644 --- a/network/proc/tables.go +++ b/service/network/proc/tables.go @@ -13,7 +13,7 @@ import ( "unicode" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/socket" ) /* diff --git a/network/proc/tables_test.go b/service/network/proc/tables_test.go similarity index 100% rename from network/proc/tables_test.go rename to service/network/proc/tables_test.go diff --git a/network/reference/ports.go b/service/network/reference/ports.go similarity index 84% rename from network/reference/ports.go rename to service/network/reference/ports.go index 3c24826d..6f6873f5 100644 --- a/network/reference/ports.go +++ b/service/network/reference/ports.go @@ -14,8 +14,8 @@ var ( 25: "SMTP", 43: "WHOIS", 53: "DNS", - 67: "DHCP-SERVER", - 68: "DHCP-CLIENT", + 67: "DHCP_SERVER", + 68: "DHCP_CLIENT", 69: "TFTP", 80: "HTTP", 110: "POP3", @@ -27,10 +27,10 @@ var ( 389: "LDAP", 443: "HTTPS", 445: "SMB", - 587: "SMTP-ALT", - 465: "SMTP-SSL", - 993: "IMAP-SSL", - 995: "POP3-SSL", + 587: "SMTP_ALT", + 465: "SMTP_SSL", + 993: "IMAP_SSL", + 995: "POP3_SSL", } portNumbers = map[string]uint16{ @@ -42,7 +42,9 @@ var ( "WHOIS": 43, "DNS": 53, "DHCP-SERVER": 67, + "DHCP_SERVER": 67, "DHCP-CLIENT": 68, + "DHCP_CLIENT": 68, "TFTP": 69, "HTTP": 80, "POP3": 110, @@ -55,9 +57,13 @@ var ( "HTTPS": 443, "SMB": 445, "SMTP-ALT": 587, + "SMTP_ALT": 587, "SMTP-SSL": 465, + "SMTP_SSL": 465, "IMAP-SSL": 993, + "IMAP_SSL": 993, "POP3-SSL": 995, + "POP3_SSL": 995, } ) diff --git a/network/reference/protocols.go b/service/network/reference/protocols.go similarity index 89% rename from network/reference/protocols.go rename to service/network/reference/protocols.go index 12202e8d..1214039e 100644 --- a/network/reference/protocols.go +++ b/service/network/reference/protocols.go @@ -73,3 +73,14 @@ func IsStreamProtocol(protocol uint8) bool { return false } } + +// IsICMP returns whether the given protocol is ICMP or ICMPv6. +func IsICMP(protocol uint8) bool { + switch protocol { + case 1, // ICMP + 58: // ICMP6 + return true + default: + return false + } +} diff --git a/network/socket/socket.go b/service/network/socket/socket.go similarity index 99% rename from network/socket/socket.go rename to service/network/socket/socket.go index 29393de8..0c294d66 100644 --- a/network/socket/socket.go +++ b/service/network/socket/socket.go @@ -44,7 +44,7 @@ type Address struct { // Info is a generic interface to both ConnectionInfo and BindInfo. type Info interface { GetPID() int - SetPID(int) + SetPID(pid int) GetUID() int GetUIDandInode() (int, int) } diff --git a/network/state/exists.go b/service/network/state/exists.go similarity index 95% rename from network/state/exists.go rename to service/network/state/exists.go index ed0c48c3..cbe81239 100644 --- a/network/state/exists.go +++ b/service/network/state/exists.go @@ -3,8 +3,8 @@ package state import ( "time" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/socket" ) const ( diff --git a/network/state/info.go b/service/network/state/info.go similarity index 89% rename from network/state/info.go rename to service/network/state/info.go index 483cd66e..306c36a0 100644 --- a/network/state/info.go +++ b/service/network/state/info.go @@ -4,8 +4,8 @@ import ( "sync" "github.com/safing/portbase/database/record" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/socket" ) // Info holds network state information as provided by the system. diff --git a/network/state/lookup.go b/service/network/state/lookup.go similarity index 97% rename from network/state/lookup.go rename to service/network/state/lookup.go index 35006b2c..39f3d2d9 100644 --- a/network/state/lookup.go +++ b/service/network/state/lookup.go @@ -3,9 +3,9 @@ package state import ( "errors" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/socket" ) // - TCP diff --git a/network/state/system_default.go b/service/network/state/system_default.go similarity index 95% rename from network/state/system_default.go rename to service/network/state/system_default.go index 4b798996..9ccf96c9 100644 --- a/network/state/system_default.go +++ b/service/network/state/system_default.go @@ -7,7 +7,7 @@ import ( "time" "github.com/safing/portbase/config" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/socket" ) func init() { diff --git a/network/state/system_linux.go b/service/network/state/system_linux.go similarity index 90% rename from network/state/system_linux.go rename to service/network/state/system_linux.go index c3e792a8..6c6bfe6f 100644 --- a/network/state/system_linux.go +++ b/service/network/state/system_linux.go @@ -3,8 +3,8 @@ package state import ( "time" - "github.com/safing/portmaster/network/proc" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/proc" + "github.com/safing/portmaster/service/network/socket" ) var ( diff --git a/network/state/system_windows.go b/service/network/state/system_windows.go similarity index 80% rename from network/state/system_windows.go rename to service/network/state/system_windows.go index 2a95a01e..fea998dd 100644 --- a/network/state/system_windows.go +++ b/service/network/state/system_windows.go @@ -1,8 +1,8 @@ package state import ( - "github.com/safing/portmaster/network/iphelper" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/iphelper" + "github.com/safing/portmaster/service/network/socket" ) var ( diff --git a/network/state/tcp.go b/service/network/state/tcp.go similarity index 97% rename from network/state/tcp.go rename to service/network/state/tcp.go index 5f8c03d7..33e053be 100644 --- a/network/state/tcp.go +++ b/service/network/state/tcp.go @@ -8,7 +8,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/network/socket" ) const ( diff --git a/network/state/udp.go b/service/network/state/udp.go similarity index 97% rename from network/state/udp.go rename to service/network/state/udp.go index 40696820..ce7139e4 100644 --- a/network/state/udp.go +++ b/service/network/state/udp.go @@ -10,9 +10,9 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/socket" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/socket" ) type udpTable struct { diff --git a/network/status.go b/service/network/status.go similarity index 100% rename from network/status.go rename to service/network/status.go diff --git a/process/api.go b/service/process/api.go similarity index 95% rename from process/api.go rename to service/process/api.go index 0f5c43a4..a2aca7f6 100644 --- a/process/api.go +++ b/service/process/api.go @@ -2,12 +2,11 @@ package process import ( "errors" - "fmt" "net/http" "strconv" "github.com/safing/portbase/api" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) func registerAPIEndpoints() error { @@ -70,7 +69,7 @@ func handleGetProcessesByProfile(ar *api.Request) (any, error) { source := ar.URLVars["source"] id := ar.URLVars["id"] if id == "" || source == "" { - return nil, api.ErrorWithStatus(fmt.Errorf("missing profile source/id"), http.StatusBadRequest) + return nil, api.ErrorWithStatus(errors.New("missing profile source/id"), http.StatusBadRequest) } result := GetProcessesWithProfile(ar.Context(), profile.ProfileSource(source), id, true) diff --git a/process/config.go b/service/process/config.go similarity index 100% rename from process/config.go rename to service/process/config.go diff --git a/process/database.go b/service/process/database.go similarity index 97% rename from process/database.go rename to service/process/database.go index 2041c6cf..091d1470 100644 --- a/process/database.go +++ b/service/process/database.go @@ -13,7 +13,7 @@ import ( "github.com/safing/portbase/database" "github.com/safing/portbase/log" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) const processDatabaseNamespace = "network:tree" @@ -72,7 +72,8 @@ func GetProcessesWithProfile(ctx context.Context, profileSource profile.ProfileS slices.SortFunc[[]*Process, *Process](procs, func(a, b *Process) int { return strings.Compare(a.processKey, b.processKey) }) - slices.CompactFunc[[]*Process, *Process](procs, func(a, b *Process) bool { + + procs = slices.CompactFunc[[]*Process, *Process](procs, func(a, b *Process) bool { return a.processKey == b.processKey }) diff --git a/process/doc.go b/service/process/doc.go similarity index 100% rename from process/doc.go rename to service/process/doc.go diff --git a/process/executable.go b/service/process/executable.go similarity index 100% rename from process/executable.go rename to service/process/executable.go diff --git a/process/find.go b/service/process/find.go similarity index 95% rename from process/find.go rename to service/process/find.go index be5afdb6..98681832 100644 --- a/process/find.go +++ b/service/process/find.go @@ -8,10 +8,10 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/netutils" - "github.com/safing/portmaster/network/packet" - "github.com/safing/portmaster/network/state" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/service/network/state" + "github.com/safing/portmaster/service/profile" ) // GetProcessWithProfile returns the process, including the profile. diff --git a/process/module.go b/service/process/module.go similarity index 91% rename from process/module.go rename to service/process/module.go index b33be8ca..cef4fe2a 100644 --- a/process/module.go +++ b/service/process/module.go @@ -4,7 +4,7 @@ import ( "os" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) var ( diff --git a/process/module_test.go b/service/process/module_test.go similarity index 65% rename from process/module_test.go rename to service/process/module_test.go index fc33c7bd..f2350d94 100644 --- a/process/module_test.go +++ b/service/process/module_test.go @@ -3,7 +3,7 @@ package process import ( "testing" - "github.com/safing/portmaster/core/pmtesting" + "github.com/safing/portmaster/service/core/pmtesting" ) func TestMain(m *testing.M) { diff --git a/process/process.go b/service/process/process.go similarity index 99% rename from process/process.go rename to service/process/process.go index b7d0cf41..4508310e 100644 --- a/process/process.go +++ b/service/process/process.go @@ -15,7 +15,7 @@ import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) const onLinux = runtime.GOOS == "linux" diff --git a/process/process_default.go b/service/process/process_default.go similarity index 100% rename from process/process_default.go rename to service/process/process_default.go diff --git a/process/process_linux.go b/service/process/process_linux.go similarity index 100% rename from process/process_linux.go rename to service/process/process_linux.go diff --git a/process/process_windows.go b/service/process/process_windows.go similarity index 100% rename from process/process_windows.go rename to service/process/process_windows.go diff --git a/process/profile.go b/service/process/profile.go similarity index 98% rename from process/profile.go rename to service/process/profile.go index 27c0f985..53599913 100644 --- a/process/profile.go +++ b/service/process/profile.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/safing/portbase/log" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) var ownPID = os.Getpid() diff --git a/process/special.go b/service/process/special.go similarity index 96% rename from process/special.go rename to service/process/special.go index aa35160a..5733c2ba 100644 --- a/process/special.go +++ b/service/process/special.go @@ -7,8 +7,8 @@ import ( "golang.org/x/sync/singleflight" "github.com/safing/portbase/log" - "github.com/safing/portmaster/network/socket" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/network/socket" + "github.com/safing/portmaster/service/profile" ) const ( diff --git a/process/tags.go b/service/process/tags.go similarity index 97% rename from process/tags.go rename to service/process/tags.go index 0eea7f49..dd8a43c5 100644 --- a/process/tags.go +++ b/service/process/tags.go @@ -4,7 +4,7 @@ import ( "errors" "sync" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) var ( diff --git a/process/tags/appimage_unix.go b/service/process/tags/appimage_unix.go similarity index 96% rename from process/tags/appimage_unix.go rename to service/process/tags/appimage_unix.go index 17cbaba2..1e1bd259 100644 --- a/process/tags/appimage_unix.go +++ b/service/process/tags/appimage_unix.go @@ -8,9 +8,9 @@ import ( "strings" "github.com/safing/portbase/log" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) func init() { diff --git a/process/tags/flatpak_unix.go b/service/process/tags/flatpak_unix.go similarity index 92% rename from process/tags/flatpak_unix.go rename to service/process/tags/flatpak_unix.go index 78eafe53..ea9e9c5a 100644 --- a/process/tags/flatpak_unix.go +++ b/service/process/tags/flatpak_unix.go @@ -3,9 +3,9 @@ package tags import ( "strings" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) func init() { diff --git a/process/tags/interpreter_unix.go b/service/process/tags/interpreter_unix.go similarity index 97% rename from process/tags/interpreter_unix.go rename to service/process/tags/interpreter_unix.go index 7e9dfdfc..7e5c28b9 100644 --- a/process/tags/interpreter_unix.go +++ b/service/process/tags/interpreter_unix.go @@ -12,9 +12,9 @@ import ( "github.com/google/shlex" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) func init() { diff --git a/process/tags/net.go b/service/process/tags/net.go similarity index 93% rename from process/tags/net.go rename to service/process/tags/net.go index 8c6196e5..ce608513 100644 --- a/process/tags/net.go +++ b/service/process/tags/net.go @@ -1,8 +1,8 @@ package tags import ( - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" ) func init() { diff --git a/process/tags/snap_unix.go b/service/process/tags/snap_unix.go similarity index 95% rename from process/tags/snap_unix.go rename to service/process/tags/snap_unix.go index 70e65299..667ac485 100644 --- a/process/tags/snap_unix.go +++ b/service/process/tags/snap_unix.go @@ -3,9 +3,9 @@ package tags import ( "strings" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) func init() { diff --git a/process/tags/svchost_windows.go b/service/process/tags/svchost_windows.go similarity index 95% rename from process/tags/svchost_windows.go rename to service/process/tags/svchost_windows.go index 44071228..83087cbc 100644 --- a/process/tags/svchost_windows.go +++ b/service/process/tags/svchost_windows.go @@ -7,9 +7,9 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/utils/osdetail" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) func init() { diff --git a/process/tags/winstore_windows.go b/service/process/tags/winstore_windows.go similarity index 95% rename from process/tags/winstore_windows.go rename to service/process/tags/winstore_windows.go index 0948be97..e41995c8 100644 --- a/process/tags/winstore_windows.go +++ b/service/process/tags/winstore_windows.go @@ -6,9 +6,9 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/process" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/process" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) func init() { diff --git a/profile/active.go b/service/profile/active.go similarity index 100% rename from profile/active.go rename to service/profile/active.go diff --git a/profile/api.go b/service/profile/api.go similarity index 98% rename from profile/api.go rename to service/profile/api.go index ca43031e..7b02e914 100644 --- a/profile/api.go +++ b/service/profile/api.go @@ -10,7 +10,7 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/formats/dsd" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/profile/binmeta" ) func registerAPIEndpoints() error { diff --git a/profile/binmeta/convert.go b/service/profile/binmeta/convert.go similarity index 100% rename from profile/binmeta/convert.go rename to service/profile/binmeta/convert.go diff --git a/profile/binmeta/find_default.go b/service/profile/binmeta/find_default.go similarity index 100% rename from profile/binmeta/find_default.go rename to service/profile/binmeta/find_default.go diff --git a/profile/binmeta/find_linux.go b/service/profile/binmeta/find_linux.go similarity index 98% rename from profile/binmeta/find_linux.go rename to service/profile/binmeta/find_linux.go index bf4357d7..b6e7816a 100644 --- a/profile/binmeta/find_linux.go +++ b/service/profile/binmeta/find_linux.go @@ -77,7 +77,8 @@ func searchDirectory(directory string, binPath string) (iconPath string, err err } return "", fmt.Errorf("failed to read directory %s: %w", directory, err) } - fmt.Println(directory) + // DEBUG: + // fmt.Println(directory) var ( bestMatch string diff --git a/profile/binmeta/find_linux_test.go b/service/profile/binmeta/find_linux_test.go similarity index 100% rename from profile/binmeta/find_linux_test.go rename to service/profile/binmeta/find_linux_test.go diff --git a/profile/binmeta/find_windows.go b/service/profile/binmeta/find_windows.go similarity index 88% rename from profile/binmeta/find_windows.go rename to service/profile/binmeta/find_windows.go index b00cb166..64117729 100644 --- a/profile/binmeta/find_windows.go +++ b/service/profile/binmeta/find_windows.go @@ -35,8 +35,9 @@ func GetIconAndName(ctx context.Context, binPath string, homeDir string) (icon * } return &Icon{ - Type: IconTypeAPI, - Value: filename, + Type: IconTypeAPI, + Value: filename, + Source: IconSourceCore, }, name, nil } @@ -63,29 +64,6 @@ func getIconAndNamefromRSS(ctx context.Context, binPath string) (png []byte, nam // return true // }) - // Get first icon. - var ( - icon *winres.Icon - iconErr error - ) - rss.WalkType(winres.RT_GROUP_ICON, func(resID winres.Identifier, langID uint16, _ []byte) bool { - icon, iconErr = rss.GetIconTranslation(resID, langID) - return iconErr != nil - }) - if iconErr != nil { - return nil, "", fmt.Errorf("failed to get icon: %w", err) - } - // Convert icon. - icoBuf := &bytes.Buffer{} - err = icon.SaveICO(icoBuf) - if err != nil { - return nil, "", fmt.Errorf("failed to save ico: %w", err) - } - png, err = ConvertICOtoPNG(icoBuf.Bytes()) - if err != nil { - return nil, "", fmt.Errorf("failed to convert ico to png: %w", err) - } - // Get name from version record. var ( versionInfo *version.Info @@ -111,5 +89,31 @@ func getIconAndNamefromRSS(ctx context.Context, binPath string) (png []byte, nam }) name = cleanFileDescription(name) + // Get first icon. + var ( + icon *winres.Icon + iconErr error + ) + rss.WalkType(winres.RT_GROUP_ICON, func(resID winres.Identifier, langID uint16, _ []byte) bool { + icon, iconErr = rss.GetIconTranslation(resID, langID) + return iconErr != nil + }) + if iconErr != nil { + return nil, name, fmt.Errorf("failed to get icon: %w", err) + } + if icon == nil { + return nil, name, errors.New("no icon in resources") + } + // Convert icon, if it exists. + icoBuf := &bytes.Buffer{} + err = icon.SaveICO(icoBuf) + if err != nil { + return nil, name, fmt.Errorf("failed to save ico: %w", err) + } + png, err = ConvertICOtoPNG(icoBuf.Bytes()) + if err != nil { + return nil, name, fmt.Errorf("failed to convert ico to png: %w", err) + } + return png, name, nil } diff --git a/profile/binmeta/find_windows_test.go b/service/profile/binmeta/find_windows_test.go similarity index 100% rename from profile/binmeta/find_windows_test.go rename to service/profile/binmeta/find_windows_test.go diff --git a/profile/binmeta/icon.go b/service/profile/binmeta/icon.go similarity index 77% rename from profile/binmeta/icon.go rename to service/profile/binmeta/icon.go index d92d0e99..64ab6e43 100644 --- a/profile/binmeta/icon.go +++ b/service/profile/binmeta/icon.go @@ -15,8 +15,9 @@ import ( // Icon describes an icon. type Icon struct { - Type IconType - Value string + Type IconType + Value string + Source IconSource } // IconType describes the type of an Icon. @@ -38,16 +39,46 @@ func (t IconType) sortOrder() int { case IconTypeFile: return 3 default: - return 100 + return 9 } } +// IconSource describes the source of an Icon. +type IconSource string + +// Supported icon sources. +const ( + IconSourceUser IconSource = "user" + IconSourceImport IconSource = "import" + IconSourceUI IconSource = "ui" + IconSourceCore IconSource = "core" +) + +func (s IconSource) sortOrder() int { + switch s { + case IconSourceUser: + return 10 + case IconSourceImport: + return 20 + case IconSourceUI: + return 30 + case IconSourceCore: + return 40 + default: + return 90 + } +} + +func (icon Icon) sortOrder() int { + return icon.Source.sortOrder() + icon.Type.sortOrder() +} + // SortAndCompactIcons sorts and compacts a list of icons. func SortAndCompactIcons(icons []Icon) []Icon { // Sort. slices.SortFunc[[]Icon, Icon](icons, func(a, b Icon) int { - aOrder := a.Type.sortOrder() - bOrder := b.Type.sortOrder() + aOrder := a.sortOrder() + bOrder := b.sortOrder() switch { case aOrder != bOrder: @@ -68,7 +99,7 @@ func SortAndCompactIcons(icons []Icon) []Icon { } // GetIconAsDataURL returns the icon data as a data URL. -func (icon *Icon) GetIconAsDataURL() (bloburl string, err error) { +func (icon Icon) GetIconAsDataURL() (bloburl string, err error) { switch icon.Type { case IconTypeFile: return "", errors.New("getting icon from file is not supported") diff --git a/profile/binmeta/icons.go b/service/profile/binmeta/icons.go similarity index 97% rename from profile/binmeta/icons.go rename to service/profile/binmeta/icons.go index ab25f7ee..595fbb10 100644 --- a/profile/binmeta/icons.go +++ b/service/profile/binmeta/icons.go @@ -92,8 +92,9 @@ func LoadAndSaveIcon(ctx context.Context, iconPath string) (*Icon, error) { return nil, fmt.Errorf("failed to import icon %s: %w", iconPath, err) } return &Icon{ - Type: IconTypeAPI, - Value: filename, + Type: IconTypeAPI, + Value: filename, + Source: IconSourceCore, }, nil } diff --git a/profile/binmeta/locations_linux.go b/service/profile/binmeta/locations_linux.go similarity index 100% rename from profile/binmeta/locations_linux.go rename to service/profile/binmeta/locations_linux.go diff --git a/profile/binmeta/name.go b/service/profile/binmeta/name.go similarity index 100% rename from profile/binmeta/name.go rename to service/profile/binmeta/name.go diff --git a/profile/binmeta/name_test.go b/service/profile/binmeta/name_test.go similarity index 100% rename from profile/binmeta/name_test.go rename to service/profile/binmeta/name_test.go diff --git a/profile/config-update.go b/service/profile/config-update.go similarity index 96% rename from profile/config-update.go rename to service/profile/config-update.go index 3a6cd246..3c31603c 100644 --- a/profile/config-update.go +++ b/service/profile/config-update.go @@ -7,8 +7,8 @@ import ( "time" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/intel/filterlists" - "github.com/safing/portmaster/profile/endpoints" + "github.com/safing/portmaster/service/intel/filterlists" + "github.com/safing/portmaster/service/profile/endpoints" ) var ( diff --git a/profile/config.go b/service/profile/config.go similarity index 99% rename from profile/config.go rename to service/profile/config.go index 18495ae9..a2b5da0a 100644 --- a/profile/config.go +++ b/service/profile/config.go @@ -4,9 +4,9 @@ import ( "strings" "github.com/safing/portbase/config" - "github.com/safing/portmaster/profile/endpoints" - "github.com/safing/portmaster/status" - "github.com/safing/spn/access/account" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/service/status" + "github.com/safing/portmaster/spn/access/account" ) // Configuration Keys. diff --git a/profile/database.go b/service/profile/database.go similarity index 100% rename from profile/database.go rename to service/profile/database.go diff --git a/profile/endpoints/annotations.go b/service/profile/endpoints/annotations.go similarity index 100% rename from profile/endpoints/annotations.go rename to service/profile/endpoints/annotations.go diff --git a/profile/endpoints/endpoint-any.go b/service/profile/endpoints/endpoint-any.go similarity index 92% rename from profile/endpoints/endpoint-any.go rename to service/profile/endpoints/endpoint-any.go index 14960489..7ec64688 100644 --- a/profile/endpoints/endpoint-any.go +++ b/service/profile/endpoints/endpoint-any.go @@ -3,7 +3,7 @@ package endpoints import ( "context" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) // EndpointAny matches anything. diff --git a/profile/endpoints/endpoint-asn.go b/service/profile/endpoints/endpoint-asn.go similarity index 96% rename from profile/endpoints/endpoint-asn.go rename to service/profile/endpoints/endpoint-asn.go index 5341f81b..20864d72 100644 --- a/profile/endpoints/endpoint-asn.go +++ b/service/profile/endpoints/endpoint-asn.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) var asnRegex = regexp.MustCompile("^AS[0-9]+$") diff --git a/profile/endpoints/endpoint-continent.go b/service/profile/endpoints/endpoint-continent.go similarity index 96% rename from profile/endpoints/endpoint-continent.go rename to service/profile/endpoints/endpoint-continent.go index f241cfa2..4ba244da 100644 --- a/profile/endpoints/endpoint-continent.go +++ b/service/profile/endpoints/endpoint-continent.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) var ( diff --git a/profile/endpoints/endpoint-country.go b/service/profile/endpoints/endpoint-country.go similarity index 96% rename from profile/endpoints/endpoint-country.go rename to service/profile/endpoints/endpoint-country.go index c8e1f6df..60a478cf 100644 --- a/profile/endpoints/endpoint-country.go +++ b/service/profile/endpoints/endpoint-country.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) var countryRegex = regexp.MustCompile(`^[A-Z]{2}$`) diff --git a/profile/endpoints/endpoint-domain.go b/service/profile/endpoints/endpoint-domain.go similarity index 97% rename from profile/endpoints/endpoint-domain.go rename to service/profile/endpoints/endpoint-domain.go index d82ccb5b..cdb6f248 100644 --- a/profile/endpoints/endpoint-domain.go +++ b/service/profile/endpoints/endpoint-domain.go @@ -6,8 +6,8 @@ import ( "regexp" "strings" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network/netutils" ) const ( diff --git a/profile/endpoints/endpoint-ip.go b/service/profile/endpoints/endpoint-ip.go similarity index 94% rename from profile/endpoints/endpoint-ip.go rename to service/profile/endpoints/endpoint-ip.go index 9797eb8d..78706932 100644 --- a/profile/endpoints/endpoint-ip.go +++ b/service/profile/endpoints/endpoint-ip.go @@ -4,7 +4,7 @@ import ( "context" "net" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) // EndpointIP matches IPs. diff --git a/profile/endpoints/endpoint-iprange.go b/service/profile/endpoints/endpoint-iprange.go similarity index 94% rename from profile/endpoints/endpoint-iprange.go rename to service/profile/endpoints/endpoint-iprange.go index 6a0b713a..14503bd8 100644 --- a/profile/endpoints/endpoint-iprange.go +++ b/service/profile/endpoints/endpoint-iprange.go @@ -4,7 +4,7 @@ import ( "context" "net" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) // EndpointIPRange matches IP ranges. diff --git a/profile/endpoints/endpoint-lists.go b/service/profile/endpoints/endpoint-lists.go similarity index 95% rename from profile/endpoints/endpoint-lists.go rename to service/profile/endpoints/endpoint-lists.go index 58e48be7..8aedb0ee 100644 --- a/profile/endpoints/endpoint-lists.go +++ b/service/profile/endpoints/endpoint-lists.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) // EndpointLists matches endpoint lists. diff --git a/profile/endpoints/endpoint-scopes.go b/service/profile/endpoints/endpoint-scopes.go similarity index 95% rename from profile/endpoints/endpoint-scopes.go rename to service/profile/endpoints/endpoint-scopes.go index c969b408..b506e2a1 100644 --- a/profile/endpoints/endpoint-scopes.go +++ b/service/profile/endpoints/endpoint-scopes.go @@ -4,8 +4,8 @@ import ( "context" "strings" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network/netutils" ) const ( diff --git a/profile/endpoints/endpoint.go b/service/profile/endpoints/endpoint.go similarity index 98% rename from profile/endpoints/endpoint.go rename to service/profile/endpoints/endpoint.go index b893a634..962d78e2 100644 --- a/profile/endpoints/endpoint.go +++ b/service/profile/endpoints/endpoint.go @@ -6,8 +6,8 @@ import ( "strconv" "strings" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/network/reference" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network/reference" ) // Endpoint describes an Endpoint Matcher. diff --git a/profile/endpoints/endpoint_test.go b/service/profile/endpoints/endpoint_test.go similarity index 100% rename from profile/endpoints/endpoint_test.go rename to service/profile/endpoints/endpoint_test.go diff --git a/profile/endpoints/endpoints.go b/service/profile/endpoints/endpoints.go similarity index 98% rename from profile/endpoints/endpoints.go rename to service/profile/endpoints/endpoints.go index 6ed3ad04..17649675 100644 --- a/profile/endpoints/endpoints.go +++ b/service/profile/endpoints/endpoints.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/intel" ) // Endpoints is a list of permitted or denied endpoints. diff --git a/profile/endpoints/endpoints_test.go b/service/profile/endpoints/endpoints_test.go similarity index 98% rename from profile/endpoints/endpoints_test.go rename to service/profile/endpoints/endpoints_test.go index 342d81d8..8dafe10d 100644 --- a/profile/endpoints/endpoints_test.go +++ b/service/profile/endpoints/endpoints_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/assert" - "github.com/safing/portmaster/core/pmtesting" - "github.com/safing/portmaster/intel" + "github.com/safing/portmaster/service/core/pmtesting" + "github.com/safing/portmaster/service/intel" ) func TestMain(m *testing.M) { diff --git a/profile/endpoints/reason.go b/service/profile/endpoints/reason.go similarity index 100% rename from profile/endpoints/reason.go rename to service/profile/endpoints/reason.go diff --git a/profile/fingerprint.go b/service/profile/fingerprint.go similarity index 100% rename from profile/fingerprint.go rename to service/profile/fingerprint.go diff --git a/profile/fingerprint_test.go b/service/profile/fingerprint_test.go similarity index 100% rename from profile/fingerprint_test.go rename to service/profile/fingerprint_test.go diff --git a/profile/framework.go b/service/profile/framework.go similarity index 100% rename from profile/framework.go rename to service/profile/framework.go diff --git a/profile/framework_test.go b/service/profile/framework_test.go similarity index 100% rename from profile/framework_test.go rename to service/profile/framework_test.go diff --git a/profile/get.go b/service/profile/get.go similarity index 100% rename from profile/get.go rename to service/profile/get.go diff --git a/profile/merge.go b/service/profile/merge.go similarity index 98% rename from profile/merge.go rename to service/profile/merge.go index 420d64f6..5e995182 100644 --- a/profile/merge.go +++ b/service/profile/merge.go @@ -7,7 +7,7 @@ import ( "time" "github.com/safing/portbase/database/record" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/profile/binmeta" ) // MergeProfiles merges multiple profiles into a new one. diff --git a/profile/meta.go b/service/profile/meta.go similarity index 100% rename from profile/meta.go rename to service/profile/meta.go diff --git a/profile/migrations.go b/service/profile/migrations.go similarity index 99% rename from profile/migrations.go rename to service/profile/migrations.go index e9b1344d..5eb94313 100644 --- a/profile/migrations.go +++ b/service/profile/migrations.go @@ -11,7 +11,7 @@ import ( "github.com/safing/portbase/database/migration" "github.com/safing/portbase/database/query" "github.com/safing/portbase/log" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/profile/binmeta" ) func registerMigrations() error { diff --git a/profile/module.go b/service/profile/module.go similarity index 93% rename from profile/module.go rename to service/profile/module.go index 547944b1..4465750d 100644 --- a/profile/module.go +++ b/service/profile/module.go @@ -10,9 +10,9 @@ import ( "github.com/safing/portbase/dataroot" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - _ "github.com/safing/portmaster/core/base" - "github.com/safing/portmaster/profile/binmeta" - "github.com/safing/portmaster/updates" + _ "github.com/safing/portmaster/service/core/base" + "github.com/safing/portmaster/service/profile/binmeta" + "github.com/safing/portmaster/service/updates" ) var ( diff --git a/profile/profile-layered-provider.go b/service/profile/profile-layered-provider.go similarity index 100% rename from profile/profile-layered-provider.go rename to service/profile/profile-layered-provider.go diff --git a/profile/profile-layered.go b/service/profile/profile-layered.go similarity index 99% rename from profile/profile-layered.go rename to service/profile/profile-layered.go index acd88da3..2635aed5 100644 --- a/profile/profile-layered.go +++ b/service/profile/profile-layered.go @@ -9,8 +9,8 @@ import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" "github.com/safing/portbase/runtime" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/profile/endpoints" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/profile/endpoints" ) // LayeredProfile combines multiple Profiles. diff --git a/profile/profile.go b/service/profile/profile.go similarity index 98% rename from profile/profile.go rename to service/profile/profile.go index 95e2b762..fff41908 100644 --- a/profile/profile.go +++ b/service/profile/profile.go @@ -15,9 +15,9 @@ import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/intel/filterlists" - "github.com/safing/portmaster/profile/binmeta" - "github.com/safing/portmaster/profile/endpoints" + "github.com/safing/portmaster/service/intel/filterlists" + "github.com/safing/portmaster/service/profile/binmeta" + "github.com/safing/portmaster/service/profile/endpoints" ) // ProfileSource is the source of the profile. diff --git a/profile/special.go b/service/profile/special.go similarity index 100% rename from profile/special.go rename to service/profile/special.go diff --git a/resolver/api.go b/service/resolver/api.go similarity index 100% rename from resolver/api.go rename to service/resolver/api.go diff --git a/resolver/block-detection.go b/service/resolver/block-detection.go similarity index 100% rename from resolver/block-detection.go rename to service/resolver/block-detection.go diff --git a/resolver/compat.go b/service/resolver/compat.go similarity index 100% rename from resolver/compat.go rename to service/resolver/compat.go diff --git a/resolver/config.go b/service/resolver/config.go similarity index 98% rename from resolver/config.go rename to service/resolver/config.go index 135c7c27..b5538d7d 100644 --- a/resolver/config.go +++ b/service/resolver/config.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/safing/portbase/config" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/status" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/status" ) // Configuration Keys. @@ -23,7 +23,7 @@ var ( // We encourage everyone who has the technical abilities to set their own preferred servers. // For a list of configuration options, see - // https://github.com/safing/portmaster/wiki/DNS-Server-Settings + // https://github.com/safing/portmaster/service/wiki/DNS-Server-Settings // Quad9 (encrypted DNS) // "dot://dns.quad9.net?ip=9.9.9.9&name=Quad9&blockedif=empty", diff --git a/resolver/doc.go b/service/resolver/doc.go similarity index 100% rename from resolver/doc.go rename to service/resolver/doc.go diff --git a/resolver/failing.go b/service/resolver/failing.go similarity index 98% rename from resolver/failing.go rename to service/resolver/failing.go index 33cee5b5..2f1ff87b 100644 --- a/resolver/failing.go +++ b/service/resolver/failing.go @@ -6,7 +6,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) var ( diff --git a/resolver/ipinfo.go b/service/resolver/ipinfo.go similarity index 100% rename from resolver/ipinfo.go rename to service/resolver/ipinfo.go diff --git a/resolver/ipinfo_test.go b/service/resolver/ipinfo_test.go similarity index 100% rename from resolver/ipinfo_test.go rename to service/resolver/ipinfo_test.go diff --git a/resolver/main.go b/service/resolver/main.go similarity index 97% rename from resolver/main.go rename to service/resolver/main.go index 2daab556..693797b5 100644 --- a/resolver/main.go +++ b/service/resolver/main.go @@ -14,9 +14,9 @@ import ( "github.com/safing/portbase/modules" "github.com/safing/portbase/notifications" "github.com/safing/portbase/utils/debug" - _ "github.com/safing/portmaster/core/base" - "github.com/safing/portmaster/intel" - "github.com/safing/portmaster/netenv" + _ "github.com/safing/portmaster/service/core/base" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/netenv" ) var module *modules.Module diff --git a/resolver/main_test.go b/service/resolver/main_test.go similarity index 97% rename from resolver/main_test.go rename to service/resolver/main_test.go index 57168227..2a2dbe44 100644 --- a/resolver/main_test.go +++ b/service/resolver/main_test.go @@ -3,7 +3,7 @@ package resolver import ( "testing" - "github.com/safing/portmaster/core/pmtesting" + "github.com/safing/portmaster/service/core/pmtesting" ) var domainFeed = make(chan string) diff --git a/resolver/metrics.go b/service/resolver/metrics.go similarity index 100% rename from resolver/metrics.go rename to service/resolver/metrics.go diff --git a/resolver/namerecord.go b/service/resolver/namerecord.go similarity index 100% rename from resolver/namerecord.go rename to service/resolver/namerecord.go diff --git a/resolver/namerecord_test.go b/service/resolver/namerecord_test.go similarity index 100% rename from resolver/namerecord_test.go rename to service/resolver/namerecord_test.go diff --git a/resolver/resolve.go b/service/resolver/resolve.go similarity index 99% rename from resolver/resolve.go rename to service/resolver/resolve.go index b9feb0a9..fe3e11ff 100644 --- a/resolver/resolve.go +++ b/service/resolver/resolve.go @@ -14,7 +14,7 @@ import ( "github.com/safing/portbase/database" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) // Errors. diff --git a/resolver/resolver-env.go b/service/resolver/resolver-env.go similarity index 97% rename from resolver/resolver-env.go rename to service/resolver/resolver-env.go index d976d311..01f58ea7 100644 --- a/resolver/resolver-env.go +++ b/service/resolver/resolver-env.go @@ -9,8 +9,8 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" ) const ( diff --git a/resolver/resolver-https.go b/service/resolver/resolver-https.go similarity index 98% rename from resolver/resolver-https.go rename to service/resolver/resolver-https.go index 2d40aac0..ed04bf92 100644 --- a/resolver/resolver-https.go +++ b/service/resolver/resolver-https.go @@ -14,7 +14,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) // HTTPSResolver is a resolver using just a single tcp connection with pipelining. diff --git a/resolver/resolver-mdns.go b/service/resolver/resolver-mdns.go similarity index 99% rename from resolver/resolver-mdns.go rename to service/resolver/resolver-mdns.go index 2e01122a..17f034c8 100644 --- a/resolver/resolver-mdns.go +++ b/service/resolver/resolver-mdns.go @@ -12,8 +12,8 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" ) // DNS Classes. diff --git a/resolver/resolver-plain.go b/service/resolver/resolver-plain.go similarity index 98% rename from resolver/resolver-plain.go rename to service/resolver/resolver-plain.go index 614f30b2..56f85458 100644 --- a/resolver/resolver-plain.go +++ b/service/resolver/resolver-plain.go @@ -9,7 +9,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) var ( diff --git a/resolver/resolver-tcp.go b/service/resolver/resolver-tcp.go similarity index 99% rename from resolver/resolver-tcp.go rename to service/resolver/resolver-tcp.go index aed64e2d..271d8808 100644 --- a/resolver/resolver-tcp.go +++ b/service/resolver/resolver-tcp.go @@ -13,7 +13,7 @@ import ( "github.com/tevino/abool" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) const ( diff --git a/resolver/resolver.go b/service/resolver/resolver.go similarity index 98% rename from resolver/resolver.go rename to service/resolver/resolver.go index e899f480..3474fd30 100644 --- a/resolver/resolver.go +++ b/service/resolver/resolver.go @@ -11,8 +11,8 @@ import ( "github.com/tevino/abool" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" ) // DNS Resolver Attributes. diff --git a/resolver/resolver_test.go b/service/resolver/resolver_test.go similarity index 100% rename from resolver/resolver_test.go rename to service/resolver/resolver_test.go diff --git a/resolver/resolvers.go b/service/resolver/resolvers.go similarity index 99% rename from resolver/resolvers.go rename to service/resolver/resolvers.go index 10226b35..93edf2a1 100644 --- a/resolver/resolvers.go +++ b/service/resolver/resolvers.go @@ -15,8 +15,8 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/netenv" - "github.com/safing/portmaster/network/netutils" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" ) const maxSearchDomains = 100 diff --git a/resolver/resolvers_test.go b/service/resolver/resolvers_test.go similarity index 100% rename from resolver/resolvers_test.go rename to service/resolver/resolvers_test.go diff --git a/resolver/reverse.go b/service/resolver/reverse.go similarity index 100% rename from resolver/reverse.go rename to service/resolver/reverse.go diff --git a/resolver/reverse_test.go b/service/resolver/reverse_test.go similarity index 100% rename from resolver/reverse_test.go rename to service/resolver/reverse_test.go diff --git a/resolver/rr_context.go b/service/resolver/rr_context.go similarity index 100% rename from resolver/rr_context.go rename to service/resolver/rr_context.go diff --git a/resolver/rrcache.go b/service/resolver/rrcache.go similarity index 98% rename from resolver/rrcache.go rename to service/resolver/rrcache.go index 1b6fdc3d..36b46e31 100644 --- a/resolver/rrcache.go +++ b/service/resolver/rrcache.go @@ -9,8 +9,8 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/nameserver/nsutil" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/nameserver/nsutil" + "github.com/safing/portmaster/service/netenv" ) // RRCache is a single-use structure to hold a DNS response. diff --git a/resolver/rrcache_test.go b/service/resolver/rrcache_test.go similarity index 100% rename from resolver/rrcache_test.go rename to service/resolver/rrcache_test.go diff --git a/resolver/scopes.go b/service/resolver/scopes.go similarity index 99% rename from resolver/scopes.go rename to service/resolver/scopes.go index 044b83fc..ac1391b1 100644 --- a/resolver/scopes.go +++ b/service/resolver/scopes.go @@ -8,7 +8,7 @@ import ( "github.com/miekg/dns" "github.com/safing/portbase/log" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) // Domain Scopes. diff --git a/resolver/test/resolving.bash b/service/resolver/test/resolving.bash similarity index 100% rename from resolver/test/resolving.bash rename to service/resolver/test/resolving.bash diff --git a/status/module.go b/service/status/module.go similarity index 88% rename from status/module.go rename to service/status/module.go index bc823832..d10d51dc 100644 --- a/status/module.go +++ b/service/status/module.go @@ -6,7 +6,7 @@ import ( "github.com/safing/portbase/modules" "github.com/safing/portbase/utils/debug" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) var module *modules.Module @@ -40,6 +40,6 @@ func AddToDebugInfo(di *debug.Info) { fmt.Sprintf("Status: %s", netenv.GetOnlineStatus()), debug.UseCodeSection|debug.AddContentLineBreaks, fmt.Sprintf("OnlineStatus: %s", netenv.GetOnlineStatus()), - fmt.Sprintf("CaptivePortal: %s", netenv.GetCaptivePortal().URL), + "CaptivePortal: "+netenv.GetCaptivePortal().URL, ) } diff --git a/status/provider.go b/service/status/provider.go similarity index 95% rename from status/provider.go rename to service/status/provider.go index fbe8d84f..5130560e 100644 --- a/status/provider.go +++ b/service/status/provider.go @@ -3,7 +3,7 @@ package status import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/runtime" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) var pushUpdate runtime.PushFunc diff --git a/status/records.go b/service/status/records.go similarity index 92% rename from status/records.go rename to service/status/records.go index 63f3f9fd..56f19e5f 100644 --- a/status/records.go +++ b/service/status/records.go @@ -4,7 +4,7 @@ import ( "sync" "github.com/safing/portbase/database/record" - "github.com/safing/portmaster/netenv" + "github.com/safing/portmaster/service/netenv" ) // SystemStatusRecord describes the overall status of the Portmaster. diff --git a/status/security_level.go b/service/status/security_level.go similarity index 100% rename from status/security_level.go rename to service/status/security_level.go diff --git a/sync/module.go b/service/sync/module.go similarity index 100% rename from sync/module.go rename to service/sync/module.go diff --git a/sync/profile.go b/service/sync/profile.go similarity index 98% rename from sync/profile.go rename to service/sync/profile.go index fa460417..22a6472b 100644 --- a/sync/profile.go +++ b/service/sync/profile.go @@ -13,8 +13,8 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/config" "github.com/safing/portbase/log" - "github.com/safing/portmaster/profile" - "github.com/safing/portmaster/profile/binmeta" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/binmeta" ) // ProfileExport holds an export of a profile. @@ -420,8 +420,9 @@ func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.Profil return nil, fmt.Errorf("%w: icon is invalid: %w", ErrImportFailed, err) } p.Icons = []binmeta.Icon{{ - Type: binmeta.IconTypeAPI, - Value: filename, + Type: binmeta.IconTypeAPI, + Value: filename, + Source: binmeta.IconSourceImport, }} } diff --git a/sync/setting_single.go b/service/sync/setting_single.go similarity index 99% rename from sync/setting_single.go rename to service/sync/setting_single.go index 24cd0cbc..8911d6e4 100644 --- a/sync/setting_single.go +++ b/service/sync/setting_single.go @@ -10,7 +10,7 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/config" "github.com/safing/portbase/formats/dsd" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) // SingleSettingExport holds an export of a single setting. diff --git a/sync/settings.go b/service/sync/settings.go similarity index 99% rename from sync/settings.go rename to service/sync/settings.go index 795d94bb..4e640d09 100644 --- a/sync/settings.go +++ b/service/sync/settings.go @@ -10,7 +10,7 @@ import ( "github.com/safing/portbase/api" "github.com/safing/portbase/config" - "github.com/safing/portmaster/profile" + "github.com/safing/portmaster/service/profile" ) // SettingsExport holds an export of settings. diff --git a/sync/util.go b/service/sync/util.go similarity index 100% rename from sync/util.go rename to service/sync/util.go diff --git a/ui/api.go b/service/ui/api.go similarity index 100% rename from ui/api.go rename to service/ui/api.go diff --git a/ui/module.go b/service/ui/module.go similarity index 100% rename from ui/module.go rename to service/ui/module.go diff --git a/ui/serve.go b/service/ui/serve.go similarity index 99% rename from ui/serve.go rename to service/ui/serve.go index 2fe7f710..1e9e5861 100644 --- a/ui/serve.go +++ b/service/ui/serve.go @@ -18,7 +18,7 @@ import ( "github.com/safing/portbase/modules" "github.com/safing/portbase/updater" "github.com/safing/portbase/utils" - "github.com/safing/portmaster/updates" + "github.com/safing/portmaster/service/updates" ) var ( diff --git a/updates/api.go b/service/updates/api.go similarity index 100% rename from updates/api.go rename to service/updates/api.go diff --git a/updates/assets/portmaster.service b/service/updates/assets/portmaster.service similarity index 100% rename from updates/assets/portmaster.service rename to service/updates/assets/portmaster.service diff --git a/updates/config.go b/service/updates/config.go similarity index 99% rename from updates/config.go rename to service/updates/config.go index a8fff098..c06e7793 100644 --- a/updates/config.go +++ b/service/updates/config.go @@ -7,7 +7,7 @@ import ( "github.com/safing/portbase/config" "github.com/safing/portbase/log" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) const cfgDevModeKey = "core/devMode" diff --git a/updates/export.go b/service/updates/export.go similarity index 99% rename from updates/export.go rename to service/updates/export.go index e17113d1..0f355d58 100644 --- a/updates/export.go +++ b/service/updates/export.go @@ -12,7 +12,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/updater" "github.com/safing/portbase/utils/debug" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) const ( diff --git a/updates/get.go b/service/updates/get.go similarity index 97% rename from updates/get.go rename to service/updates/get.go index 2cf7acf7..c133ae1f 100644 --- a/updates/get.go +++ b/service/updates/get.go @@ -4,7 +4,7 @@ import ( "path" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) // GetPlatformFile returns the latest platform specific file identified by the given identifier. diff --git a/updates/helper/electron.go b/service/updates/helper/electron.go similarity index 100% rename from updates/helper/electron.go rename to service/updates/helper/electron.go diff --git a/updates/helper/indexes.go b/service/updates/helper/indexes.go similarity index 94% rename from updates/helper/indexes.go rename to service/updates/helper/indexes.go index 7af8a610..8a272ea5 100644 --- a/updates/helper/indexes.go +++ b/service/updates/helper/indexes.go @@ -25,6 +25,8 @@ const ( ReleaseChannelSupport = "support" ) +const jsonSuffix = ".json" + // SetIndexes sets the update registry indexes and also configures the registry // to use pre-releases based on the channel. func SetIndexes( @@ -51,12 +53,12 @@ func SetIndexes( // Always add the stable index as a base. registry.AddIndex(updater.Index{ - Path: ReleaseChannelStable + ".json", + Path: ReleaseChannelStable + jsonSuffix, AutoDownload: autoDownload, }) // Add beta index if in beta or staging channel. - indexPath := ReleaseChannelBeta + ".json" + indexPath := ReleaseChannelBeta + jsonSuffix if releaseChannel == ReleaseChannelBeta || releaseChannel == ReleaseChannelStaging || (releaseChannel == "" && indexExists(registry, indexPath)) { @@ -74,7 +76,7 @@ func SetIndexes( } // Add staging index if in staging channel. - indexPath = ReleaseChannelStaging + ".json" + indexPath = ReleaseChannelStaging + jsonSuffix if releaseChannel == ReleaseChannelStaging || (releaseChannel == "" && indexExists(registry, indexPath)) { registry.AddIndex(updater.Index{ @@ -91,7 +93,7 @@ func SetIndexes( } // Add support index if in support channel. - indexPath = ReleaseChannelSupport + ".json" + indexPath = ReleaseChannelSupport + jsonSuffix if releaseChannel == ReleaseChannelSupport || (releaseChannel == "" && indexExists(registry, indexPath)) { registry.AddIndex(updater.Index{ diff --git a/updates/helper/signing.go b/service/updates/helper/signing.go similarity index 100% rename from updates/helper/signing.go rename to service/updates/helper/signing.go diff --git a/updates/helper/updates.go b/service/updates/helper/updates.go similarity index 100% rename from updates/helper/updates.go rename to service/updates/helper/updates.go diff --git a/updates/main.go b/service/updates/main.go similarity index 98% rename from updates/main.go rename to service/updates/main.go index 02f46075..218675b8 100644 --- a/updates/main.go +++ b/service/updates/main.go @@ -14,7 +14,7 @@ import ( "github.com/safing/portbase/log" "github.com/safing/portbase/modules" "github.com/safing/portbase/updater" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) const ( @@ -226,7 +226,7 @@ func TriggerUpdate(forceIndexCheck, downloadAll bool) error { updateASAP = true case !forceIndexCheck && !enableSoftwareUpdates() && !enableIntelUpdates(): - return fmt.Errorf("automatic updating is disabled") + return errors.New("automatic updating is disabled") default: if forceIndexCheck { @@ -254,7 +254,7 @@ func TriggerUpdate(forceIndexCheck, downloadAll bool) error { func DisableUpdateSchedule() error { switch module.Status() { case modules.StatusStarting, modules.StatusOnline, modules.StatusStopping: - return fmt.Errorf("module already online") + return errors.New("module already online") } disableTaskSchedule = true diff --git a/updates/notify.go b/service/updates/notify.go similarity index 100% rename from updates/notify.go rename to service/updates/notify.go diff --git a/updates/os_integration_default.go b/service/updates/os_integration_default.go similarity index 100% rename from updates/os_integration_default.go rename to service/updates/os_integration_default.go diff --git a/updates/os_integration_linux.go b/service/updates/os_integration_linux.go similarity index 100% rename from updates/os_integration_linux.go rename to service/updates/os_integration_linux.go diff --git a/updates/restart.go b/service/updates/restart.go similarity index 100% rename from updates/restart.go rename to service/updates/restart.go diff --git a/updates/state.go b/service/updates/state.go similarity index 100% rename from updates/state.go rename to service/updates/state.go diff --git a/updates/upgrader.go b/service/updates/upgrader.go similarity index 98% rename from updates/upgrader.go rename to service/updates/upgrader.go index d350b760..03ec64db 100644 --- a/updates/upgrader.go +++ b/service/updates/upgrader.go @@ -21,7 +21,7 @@ import ( "github.com/safing/portbase/rng" "github.com/safing/portbase/updater" "github.com/safing/portbase/utils/renameio" - "github.com/safing/portmaster/updates/helper" + "github.com/safing/portmaster/service/updates/helper" ) const ( @@ -108,7 +108,7 @@ func upgradeCoreNotify() error { pmCoreUpdate = newFile // check for new version - if info.GetInfo().Version != pmCoreUpdate.Version() { + if info.VersionNumber() != pmCoreUpdate.Version() { n := notifications.Notify(¬ifications.Notification{ EventID: "updates:core-update-available", Type: notifications.Info, diff --git a/spn/TESTING.md b/spn/TESTING.md new file mode 100644 index 00000000..88a82c33 --- /dev/null +++ b/spn/TESTING.md @@ -0,0 +1,26 @@ +# Testing SPN + +This page documents ways to test if the SPN works as intended. + +⚠ Work in Progress. Currently we are just collecting helpful things we find. + +## Test Multi-Identity Routing + +In order to test if the multi-identity routing is working, you can request multiple websites to display your public IP. +If they show different values, multi-identity routing is working. + +### Websites + +- +- +- +- + +### Terminal + +```sh +curl https://icanhazip.com +curl https://ipecho.net/plain +curl https://ipinfo.io/ip +curl https://ipinfo.tw/ip +``` diff --git a/spn/TRADEMARKS b/spn/TRADEMARKS new file mode 100644 index 00000000..1bff5e79 --- /dev/null +++ b/spn/TRADEMARKS @@ -0,0 +1,5 @@ +The names "Safing", "Portmaster", "SPN" and their logos are trademarks owned by Safing ICS Technologies GmbH (Austria). + +Although our code is free, it is very important that we strictly enforce our trademark rights, in order to be able to protect our users against people who use the marks to commit fraud. This means that, while you have considerable freedom to redistribute and modify our software, there are tight restrictions on your ability to use our names and logos in ways which fall in the domain of trademark law, even when built into binaries that we provide. + +This file is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. Parts of it were taken from https://www.mozilla.org/en-US/foundation/licensing/. diff --git a/spn/access/account/auth.go b/spn/access/account/auth.go new file mode 100644 index 00000000..d93e6bf5 --- /dev/null +++ b/spn/access/account/auth.go @@ -0,0 +1,65 @@ +package account + +import ( + "errors" + "net/http" +) + +// Authentication Headers. +const ( + AuthHeaderDevice = "Device-17" + AuthHeaderToken = "Token-17" + AuthHeaderNextToken = "Next-Token-17" + AuthHeaderNextTokenDeprecated = "Next_token_17" +) + +// Errors. +var ( + ErrMissingDeviceID = errors.New("missing device ID") + ErrMissingToken = errors.New("missing token") +) + +// AuthToken holds an authentication token. +type AuthToken struct { + Device string + Token string +} + +// GetAuthTokenFromRequest extracts an authentication token from a request. +func GetAuthTokenFromRequest(request *http.Request) (*AuthToken, error) { + device := request.Header.Get(AuthHeaderDevice) + if device == "" { + return nil, ErrMissingDeviceID + } + token := request.Header.Get(AuthHeaderToken) + if token == "" { + return nil, ErrMissingToken + } + + return &AuthToken{ + Device: device, + Token: token, + }, nil +} + +// ApplyTo applies the authentication token to a request. +func (at *AuthToken) ApplyTo(request *http.Request) { + request.Header.Set(AuthHeaderDevice, at.Device) + request.Header.Set(AuthHeaderToken, at.Token) +} + +// GetNextTokenFromResponse extracts an authentication token from a response. +func GetNextTokenFromResponse(resp *http.Response) (token string, ok bool) { + token = resp.Header.Get(AuthHeaderNextToken) + if token == "" { + // TODO: Remove when fixed on server. + token = resp.Header.Get(AuthHeaderNextTokenDeprecated) + } + + return token, token != "" +} + +// ApplyNextTokenToResponse applies the next authentication token to a response. +func ApplyNextTokenToResponse(w http.ResponseWriter, token string) { + w.Header().Set(AuthHeaderNextToken, token) +} diff --git a/spn/access/account/client.go b/spn/access/account/client.go new file mode 100644 index 00000000..d6d0f879 --- /dev/null +++ b/spn/access/account/client.go @@ -0,0 +1,14 @@ +package account + +// Customer Agent URLs. +const ( + CAAuthenticateURL = "/authenticate" + CAProfileURL = "/user/profile" + CAGetTokensURL = "/tokens" +) + +// Customer Hub URLs. +const ( + CHAuthenticateURL = "/v1/authenticate" + CHUserProfileURL = "/v1/user_profile" +) diff --git a/spn/access/account/types.go b/spn/access/account/types.go new file mode 100644 index 00000000..f92f9f65 --- /dev/null +++ b/spn/access/account/types.go @@ -0,0 +1,137 @@ +package account + +import ( + "time" + + "golang.org/x/exp/slices" +) + +// User, Subscription and Charge states. +const ( + // UserStateNone is only used within Portmaster for saving information for + // logging into the same device. + UserStateNone = "" + UserStateFresh = "fresh" + UserStateQueued = "queued" + UserStateApproved = "approved" + UserStateSuspended = "suspended" + UserStateLoggedOut = "loggedout" // Portmaster only. + + SubscriptionStateManual = "manual" // Manual renewal. + SubscriptionStateActive = "active" // Automatic renewal. + SubscriptionStateCancelled = "cancelled" // Automatic, but canceled. + + ChargeStatePending = "pending" + ChargeStateCompleted = "completed" + ChargeStateDead = "dead" +) + +// Agent and Hub return statuses. +const ( + // StatusInvalidAuth [401 Unauthorized] is returned when the credentials are + // invalid or the user was logged out. + StatusInvalidAuth = 401 + // StatusNoAccess [403 Forbidden] is returned when the user does not have + // an active subscription or the subscription does not include the required + // feature for the request. + StatusNoAccess = 403 + // StatusInvalidDevice [410 Gone] is returned when the device trying to + // log into does not exist. + StatusInvalidDevice = 410 + // StatusReachedDeviceLimit [409 Conflict] is returned when the device limit is reached. + StatusReachedDeviceLimit = 409 + // StatusDeviceInactive [423 Locked] is returned when the device is locked. + StatusDeviceInactive = 423 + // StatusNotLoggedIn [412 Precondition] is returned by the Portmaster, if an action required to be logged in, but the user is not logged in. + StatusNotLoggedIn = 412 + + // StatusUnknownError is a special status code that signifies an unknown or + // unexpected error by the API. + StatusUnknownError = -1 + // StatusConnectionError is a special status code that signifies a + // connection error. + StatusConnectionError = -2 +) + +// User describes an SPN user account. +type User struct { + Username string `json:"username"` + State string `json:"state"` + Balance int `json:"balance"` + Device *Device `json:"device"` + Subscription *Subscription `json:"subscription"` + CurrentPlan *Plan `json:"current_plan"` + NextPlan *Plan `json:"next_plan"` + View *View `json:"view"` +} + +// MayUseSPN returns whether the user may currently use the SPN. +func (u *User) MayUseSPN() bool { + return u.MayUse(FeatureSPN) +} + +// MayUsePrioritySupport returns whether the user may currently use the priority support. +func (u *User) MayUsePrioritySupport() bool { + return u.MayUse(FeatureSafingSupport) +} + +// MayUse returns whether the user may currently use the feature identified by +// the given feature ID. +// Leave feature ID empty to check without feature. +func (u *User) MayUse(featureID FeatureID) bool { + switch { + case u == nil: + // We need a user, obviously. + case u.State != UserStateApproved: + // Only approved users may use the SPN. + case u.Subscription == nil: + // Need a subscription. + case u.Subscription.EndsAt == nil: + case time.Now().After(*u.Subscription.EndsAt): + // Subscription needs to be active. + case u.CurrentPlan == nil: + // Need a plan / package. + case featureID != "" && + !slices.Contains(u.CurrentPlan.FeatureIDs, featureID): + // Required feature ID must be in plan / package feature IDs. + default: + // All checks passed! + return true + } + return false +} + +// Device describes a device of an SPN user. +type Device struct { + Name string `json:"name"` + ID string `json:"id"` +} + +// Subscription describes an SPN subscription. +type Subscription struct { + EndsAt *time.Time `json:"ends_at"` + State string `json:"state"` + NextBillingDate *time.Time `json:"next_billing_date"` + PaymentProvider string `json:"payment_provider"` +} + +// FeatureID defines a feature that requires a plan/subscription. +type FeatureID string + +// A list of all supported features. +const ( + FeatureSPN = FeatureID("spn") + FeatureSafingSupport = FeatureID("support") + FeatureHistory = FeatureID("history") + FeatureBWVis = FeatureID("bw-vis") + FeatureVPNCompat = FeatureID("vpn-compat") +) + +// Plan describes an SPN subscription plan. +type Plan struct { + Name string `json:"name"` + Amount int `json:"amount"` + Months int `json:"months"` + Renewable bool `json:"renewable"` + FeatureIDs []FeatureID `json:"feature_ids"` +} diff --git a/spn/access/account/view.go b/spn/access/account/view.go new file mode 100644 index 00000000..818bdfa6 --- /dev/null +++ b/spn/access/account/view.go @@ -0,0 +1,123 @@ +package account + +import ( + "fmt" + "strings" + "time" +) + +// View holds metadata that assists in displaying account information. +type View struct { + Message string + ShowAccountData bool + ShowAccountButton bool + ShowLoginButton bool + ShowRefreshButton bool + ShowLogoutButton bool +} + +// UpdateView updates the view and handles plan/package fallbacks. +func (u *User) UpdateView(requestStatusCode int) { + v := &View{} + + // Clean up naming and fallbacks when finished. + defer func() { + // Display "Free" package if no plan is set or if it expired. + switch { + case u.CurrentPlan == nil, + u.Subscription == nil, + u.Subscription.EndsAt == nil: + // Reset to free plan. + u.CurrentPlan = &Plan{ + Name: "Free", + } + u.Subscription = nil + + case u.Subscription.NextBillingDate != nil: + // Subscription is on auto-renew. + // Wait for update from server. + + case time.Since(*u.Subscription.EndsAt) > 0: + // Reset to free plan. + u.CurrentPlan = &Plan{ + Name: "Free", + } + u.Subscription = nil + } + + // Prepend "Portmaster " to plan name. + // TODO: Remove when Plan/Package naming has been updated. + if u.CurrentPlan != nil && !strings.HasPrefix(u.CurrentPlan.Name, "Portmaster ") { + u.CurrentPlan.Name = "Portmaster " + u.CurrentPlan.Name + } + + // Apply new view to user. + u.View = v + }() + + // Set view data based on return code. + switch requestStatusCode { + case StatusInvalidAuth, StatusInvalidDevice, StatusDeviceInactive: + // Account deleted or Device inactive or deleted. + // When using token based auth, there is no difference between these cases. + v.Message = "This device may have been deactivated or removed from your account. Please log in again." + v.ShowAccountData = true + v.ShowAccountButton = true + v.ShowLoginButton = true + v.ShowLogoutButton = true + return + + case StatusUnknownError: + v.Message = "There is an unknown error in the communication with the account server. The shown information may not be accurate. " + + case StatusConnectionError: + v.Message = "Portmaster could not connect to the account server. The shown information may not be accurate. " + } + + // Set view data based on profile data. + switch { + case u.State == UserStateLoggedOut: + // User logged out. + v.ShowAccountButton = true + v.ShowLoginButton = true + return + + case u.State == UserStateSuspended: + // Account is suspended. + v.Message += fmt.Sprintf("Your account (%s) was suspended. Please contact support for details.", u.Username) + v.ShowAccountButton = true + v.ShowRefreshButton = true + v.ShowLogoutButton = true + return + + case u.Subscription == nil || u.Subscription.EndsAt == nil: + // Account has never had a subscription. + v.Message += "Get more features. Upgrade today." + + case u.Subscription.NextBillingDate != nil: + switch { + case time.Since(*u.Subscription.NextBillingDate) > 0: + v.Message += "Your auto-renewal seems to be delayed. Please refresh and check the status of your payment. Payment information may be delayed." + case time.Until(*u.Subscription.NextBillingDate) < 24*time.Hour: + v.Message += "Your subscription will auto-renew soon. Please note that payment information may be delayed." + } + + case time.Since(*u.Subscription.EndsAt) > 0: + // Subscription expired. + if u.CurrentPlan != nil { + v.Message += fmt.Sprintf("Your package %s has ended. Extend it on the Account Page.", u.CurrentPlan.Name) + } else { + v.Message += "Your package has ended. Extend it on the Account Page." + } + + case time.Until(*u.Subscription.EndsAt) < 7*24*time.Hour: + // Add generic ending soon message if the package ends in less than 7 days. + v.Message += "Your package ends soon. Extend it on the Account Page." + } + + // Defaults for generally good accounts. + v.ShowAccountData = true + v.ShowAccountButton = true + v.ShowRefreshButton = true + v.ShowLogoutButton = true +} diff --git a/spn/access/api.go b/spn/access/api.go new file mode 100644 index 00000000..c97370bc --- /dev/null +++ b/spn/access/api.go @@ -0,0 +1,169 @@ +package access + +import ( + "errors" + "fmt" + "net/http" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/access/account" +) + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/account/login`, + Write: api.PermitAdmin, + WriteMethod: http.MethodPost, + HandlerFunc: handleLogin, + Name: "SPN Login", + Description: "Log into your SPN account.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/account/logout`, + Write: api.PermitAdmin, + WriteMethod: http.MethodDelete, + ActionFunc: handleLogout, + Name: "SPN Logout", + Description: "Logout from your SPN account.", + Parameters: []api.Parameter{ + { + Method: http.MethodDelete, + Field: "purge", + Value: "", + Description: "If set, account data is purged. Otherwise, the username and device ID are kept in order to log into the same device when logging in with the same user again.", + }, + }, + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/account/user/profile`, + Read: api.PermitUser, + ReadMethod: http.MethodGet, + RecordFunc: handleGetUserProfile, + Name: "SPN User Profile", + Description: "Get the user profile of the logged in SPN account.", + Parameters: []api.Parameter{ + { + Method: http.MethodGet, + Field: "refresh", + Value: "", + Description: "If set, the user profile is freshly fetched from the account server.", + }, + }, + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `account/features`, + Read: api.PermitUser, + ReadMethod: http.MethodGet, + StructFunc: func(_ *api.Request) (i interface{}, err error) { + return struct { + Features []Feature + }{ + Features: features, + }, nil + }, + Name: "Get Account Features", + Description: "Returns all account features.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `account/features/{id:[A-Za-z0-9_-]+}/icon`, + Read: api.PermitUser, + ReadMethod: http.MethodGet, + Name: "Returns the image of the featuare", + MimeType: "image/svg+xml", + DataFunc: func(ar *api.Request) (data []byte, err error) { + featureID, ok := ar.URLVars["id"] + if !ok { + return nil, errors.New("invalid feature id") + } + + for _, feature := range features { + if feature.ID == featureID { + return []byte(feature.icon), nil + } + } + + return nil, errors.New("feature id not found") + }, + }); err != nil { + return err + } + + return nil +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + // Get username and password. + username, password, ok := r.BasicAuth() + // Request, if omitted. + if !ok || username == "" || password == "" { + w.Header().Set("WWW-Authenticate", "Basic realm=SPN Login") + http.Error(w, "Login with your SPN account.", http.StatusUnauthorized) + return + } + + // Process login. + user, code, err := Login(username, password) + if err != nil { + log.Warningf("spn/access: failed to login: %s", err) + if code == 0 { + http.Error(w, "Internal error: "+err.Error(), http.StatusInternalServerError) + } else { + http.Error(w, err.Error(), code) + } + return + } + + // Return success. + _, _ = w.Write([]byte( + fmt.Sprintf("Now logged in as %s as device %s", user.Username, user.Device.Name), + )) +} + +func handleLogout(ar *api.Request) (msg string, err error) { + purge := ar.URL.Query().Get("purge") != "" + err = Logout(false, purge) + switch { + case err != nil: + log.Warningf("spn/access: failed to logout: %s", err) + return "", err + case purge: + return "Logged out and user data purged.", nil + default: + return "Logged out.", nil + } +} + +func handleGetUserProfile(ar *api.Request) (r record.Record, err error) { + // Check if we are already authenticated. + user, err := GetUser() + if err != nil || user.State == account.UserStateNone { + return nil, api.ErrorWithStatus( + ErrNotLoggedIn, + account.StatusInvalidAuth, + ) + } + + // Should we refresh the user profile? + if ar.URL.Query().Get("refresh") != "" { + user, _, err = UpdateUser() + if err != nil { + return nil, err + } + } + + return user, nil +} diff --git a/spn/access/client.go b/spn/access/client.go new file mode 100644 index 00000000..f22bb9e9 --- /dev/null +++ b/spn/access/client.go @@ -0,0 +1,550 @@ +package access + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/access/account" + "github.com/safing/portmaster/spn/access/token" +) + +// Client URLs. +const ( + AccountServer = "https://api.account.safing.io" + LoginPath = "/api/v1/authenticate" + UserProfilePath = "/api/v1/user/profile" + TokenRequestSetupPath = "/api/v1/token/request/setup" //nolint:gosec + TokenRequestIssuePath = "/api/v1/token/request/issue" //nolint:gosec + HealthCheckPath = "/api/v1/health" + + defaultDataFormat = dsd.CBOR + defaultRequestTimeout = 30 * time.Second +) + +var ( + accountClient = &http.Client{} + clientRequestLock sync.Mutex + + // EnableAfterLogin automatically enables the SPN subsystem/module after login. + EnableAfterLogin = true +) + +type clientRequestOptions struct { + method string + url string + send interface{} + recv interface{} + requestTimeout time.Duration + dataFormat uint8 + setAuthToken bool + requireNextAuthToken bool + logoutOnAuthError bool + requestSetupFunc func(*http.Request) error +} + +func makeClientRequest(opts *clientRequestOptions) (resp *http.Response, err error) { + // Get request timeout. + if opts.requestTimeout == 0 { + opts.requestTimeout = defaultRequestTimeout + } + // Get context for request. + var ctx context.Context + var cancel context.CancelFunc + if module.Online() { + // Only use module context if online. + ctx, cancel = context.WithTimeout(module.Ctx, opts.requestTimeout) + defer cancel() + } else { + // Otherwise, use the background context. + ctx, cancel = context.WithTimeout(context.Background(), opts.requestTimeout) + defer cancel() + } + + // Create new request. + request, err := http.NewRequestWithContext(ctx, opts.method, opts.url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request structure: %w", err) + } + + // Prepare body and content type. + if opts.dataFormat == dsd.AUTO { + opts.dataFormat = defaultDataFormat + } + if opts.send != nil { + // Add data to body. + err = dsd.DumpToHTTPRequest(request, opts.send, opts.dataFormat) + if err != nil { + return nil, fmt.Errorf("failed to add request body: %w", err) + } + } else { + // Set requested HTTP response format. + _, err = dsd.RequestHTTPResponseFormat(request, opts.dataFormat) + if err != nil { + return nil, fmt.Errorf("failed to set requested response format: %w", err) + } + } + + // Get auth token to apply to request. + var authToken *AuthTokenRecord + if opts.setAuthToken { + authToken, err = GetAuthToken() + if err != nil { + return nil, ErrNotLoggedIn + } + authToken.Token.ApplyTo(request) + } + + // Do any additional custom request setup. + if opts.requestSetupFunc != nil { + err = opts.requestSetupFunc(request) + if err != nil { + return nil, err + } + } + + // Make request. + resp, err = accountClient.Do(request) + if err != nil { + updateUserWithFailedRequest(account.StatusConnectionError, false) + tokenIssuerFailed() + return nil, fmt.Errorf("http request failed: %w", err) + } + log.Debugf("spn/access: request to %s returned %s", request.URL, resp.Status) + defer func() { + _ = resp.Body.Close() + }() + // Handle request error. + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + // All good! + + case account.StatusInvalidAuth, account.StatusInvalidDevice: + // Wrong username / password. + updateUserWithFailedRequest(resp.StatusCode, true) + return resp, ErrInvalidCredentials + + case account.StatusReachedDeviceLimit: + // Device limit is reached. + updateUserWithFailedRequest(resp.StatusCode, true) + return resp, ErrDeviceLimitReached + + case account.StatusDeviceInactive: + // Device is locked. + updateUserWithFailedRequest(resp.StatusCode, true) + return resp, ErrDeviceIsLocked + + default: + updateUserWithFailedRequest(account.StatusUnknownError, false) + tokenIssuerFailed() + return resp, fmt.Errorf("unexpected reply: [%d] %s", resp.StatusCode, resp.Status) + } + + // Save next auth token. + if authToken != nil { + err = authToken.Update(resp) + if err != nil { + if errors.Is(err, account.ErrMissingToken) { + if opts.requireNextAuthToken { + return resp, fmt.Errorf("failed to save next auth token: %w", err) + } + } else { + return resp, fmt.Errorf("failed to save next auth token: %w", err) + } + } + } else if opts.requireNextAuthToken { + return resp, fmt.Errorf("failed to save next auth token: %w", account.ErrMissingToken) + } + + // Load response data. + if opts.recv != nil { + _, err = dsd.LoadFromHTTPResponse(resp, opts.recv) + if err != nil { + return resp, fmt.Errorf("failed to parse response: %w", err) + } + } + + tokenIssuerIsFailing.UnSet() + return resp, nil +} + +func updateUserWithFailedRequest(statusCode int, disableSubscription bool) { + // Get user from database. + user, err := GetUser() + if err != nil { + if !errors.Is(err, ErrNotLoggedIn) { + log.Warningf("spn/access: failed to get user to update with failed request: %s", err) + } + return + } + + func() { + user.Lock() + defer user.Unlock() + + // Ignore update if user state is undefined or logged out. + if user.State == "" || user.State == account.UserStateLoggedOut { + return + } + + // Disable the subscription if desired. + if disableSubscription && user.Subscription != nil { + user.Subscription.EndsAt = nil + } + + // Update view with the status code and save user. + user.UpdateView(statusCode) + }() + + err = user.Save() + if err != nil { + log.Warningf("spn/access: failed to save user after update with failed request: %s", err) + } +} + +// Login logs the user into the SPN account with the given username and password. +func Login(username, password string) (user *UserRecord, code int, err error) { + clientRequestLock.Lock() + defer clientRequestLock.Unlock() + + // Trigger account update when done. + defer module.TriggerEvent(AccountUpdateEvent, nil) + + // Get previous user. + previousUser, err := GetUser() + if err != nil { + if !errors.Is(err, ErrNotLoggedIn) { + log.Warningf("spn/access: failed to get previous for re-login: %s", err) + } + previousUser = nil + } + + // Create request options. + userAccount := &account.User{} + requestOptions := &clientRequestOptions{ + method: http.MethodPost, + url: AccountServer + LoginPath, + recv: userAccount, + dataFormat: dsd.JSON, + requestSetupFunc: func(request *http.Request) error { + // Add username and password. + request.SetBasicAuth(username, password) + + // Try to reuse the device ID, if the username matches the previous user. + if previousUser != nil && username == previousUser.Username { + request.Header.Set(account.AuthHeaderDevice, previousUser.Device.ID) + } + + return nil + }, + } + + // Make request. + resp, err := makeClientRequest(requestOptions) //nolint:bodyclose // Body is closed in function. + if err != nil { + if resp != nil && resp.StatusCode == account.StatusInvalidDevice { + // Try again without the previous device ID. + previousUser = nil + log.Info("spn/access: retrying log in without re-using previous device ID") + resp, err = makeClientRequest(requestOptions) //nolint:bodyclose // Body is closed in function. + } + if err != nil { + if resp != nil { + return nil, resp.StatusCode, err + } + return nil, 0, err + } + } + + // Save new user. + now := time.Now() + user = &UserRecord{ + User: userAccount, + LoggedInAt: &now, + } + + user.UpdateView(0) + err = user.Save() + if err != nil { + return user, resp.StatusCode, fmt.Errorf("failed to save new user profile: %w", err) + } + + // Save initial auth token. + err = SaveNewAuthToken(user.Device.ID, resp) + if err != nil { + return user, resp.StatusCode, fmt.Errorf("failed to save initial auth token: %w", err) + } + + // Enable the SPN right after login. + if user.MayUseSPN() && EnableAfterLogin { + enableSPN() + } + + log.Infof("spn/access: logged in as %q on device %q", user.Username, user.Device.Name) + return user, resp.StatusCode, nil +} + +// Logout logs the user out of the SPN account. +// Specify "shallow" to keep user data in order to display data in the +// UI - preferably when logged out be the server. +// Specify "purge" in order to fully delete all user account data, even +// the device ID so that logging in again will create a new device. +func Logout(shallow, purge bool) error { + clientRequestLock.Lock() + defer clientRequestLock.Unlock() + + // Trigger account update when done. + defer module.TriggerEvent(AccountUpdateEvent, nil) + + // Clear caches. + clearUserCaches() + + // Clear tokens. + clearTokens() + + // Delete auth token. + err := db.Delete(authTokenRecordKey) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to delete auth token: %w", err) + } + + // Delete all user data if purging. + if purge { + err := db.Delete(userRecordKey) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to delete user: %w", err) + } + + // Disable SPN when the user logs out directly. + disableSPN() + + log.Info("spn/access: logged out and purged data") + return nil + } + + // Else, just update the user. + user, err := GetUser() + if err != nil { + if errors.Is(err, ErrNotLoggedIn) { + return nil + } + return fmt.Errorf("failed to load user for logout: %w", err) + } + + func() { + user.Lock() + defer user.Unlock() + + if shallow { + // Shallow logout: User stays logged in the UI to display status when + // logged out from the Portmaster or Customer Hub. + user.User.State = account.UserStateLoggedOut + } else { + // Proper logout: User is logged out from UI. + // Reset all user data, except for username and device ID in order to log + // into the same device again. + user.User = &account.User{ + Username: user.Username, + Device: &account.Device{ + ID: user.Device.ID, + }, + } + user.LoggedInAt = &time.Time{} + } + user.UpdateView(0) + }() + err = user.Save() + if err != nil { + return fmt.Errorf("failed to save user for logout: %w", err) + } + + if shallow { + log.Info("spn/access: logged out shallow") + } else { + log.Info("spn/access: logged out") + + // Disable SPN when the user logs out directly. + disableSPN() + } + + return nil +} + +// UpdateUser fetches the current user information from the server. +func UpdateUser() (user *UserRecord, statusCode int, err error) { + clientRequestLock.Lock() + defer clientRequestLock.Unlock() + + // Trigger account update when done. + defer module.TriggerEvent(AccountUpdateEvent, nil) + + // Create request options. + userData := &account.User{} + requestOptions := &clientRequestOptions{ + method: http.MethodGet, + url: AccountServer + UserProfilePath, + recv: userData, + dataFormat: dsd.JSON, + setAuthToken: true, + requireNextAuthToken: true, + logoutOnAuthError: true, + } + + // Make request. + resp, err := makeClientRequest(requestOptions) //nolint:bodyclose // Body is closed in function. + if err != nil { + if resp != nil { + return nil, resp.StatusCode, err + } + return nil, 0, err + } + + // Save to previous user, if exists. + previousUser, err := GetUser() + if err == nil { + func() { + previousUser.Lock() + defer previousUser.Unlock() + previousUser.User = userData + previousUser.UpdateView(resp.StatusCode) + }() + err := previousUser.Save() + if err != nil { + log.Warningf("spn/access: failed to save updated user profile: %s", err) + } + + // Notify user of nearing end of package. + notifyOfPackageEnd(previousUser) + + log.Infof("spn/access: got user profile, updated existing") + return previousUser, resp.StatusCode, nil + } + + // Else, save as new user. + now := time.Now() + newUser := &UserRecord{ + User: userData, + LoggedInAt: &now, + } + newUser.UpdateView(resp.StatusCode) + err = newUser.Save() + if err != nil { + log.Warningf("spn/access: failed to save new user profile: %s", err) + } + + // Notify user of nearing end of package. + notifyOfPackageEnd(newUser) + + log.Infof("spn/access: got user profile, saved as new") + return newUser, resp.StatusCode, nil +} + +// UpdateTokens fetches more tokens for handlers that need it. +func UpdateTokens() error { + clientRequestLock.Lock() + defer clientRequestLock.Unlock() + + // Check if the user may request tokens. + user, err := GetUser() + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + if !user.MayUseTheSPN() { + return ErrMayNotUseSPN + } + + // Create setup request, return if not required. + setupRequest, setupRequired := token.CreateSetupRequest() + var setupResponse *token.SetupResponse + if setupRequired { + // Request setup data. + setupResponse = &token.SetupResponse{} + _, err := makeClientRequest(&clientRequestOptions{ //nolint:bodyclose // Body is closed in function. + method: http.MethodPost, + url: AccountServer + TokenRequestSetupPath, + send: setupRequest, + recv: setupResponse, + dataFormat: dsd.MsgPack, + setAuthToken: true, + logoutOnAuthError: true, + }) + if err != nil { + return fmt.Errorf("failed to request setup data: %w", err) + } + } + + // Create request for issuing new tokens. + tokenRequest, requestRequired, err := token.CreateTokenRequest(setupResponse) + if err != nil { + return fmt.Errorf("failed to create token request: %w", err) + } + if !requestRequired { + return nil + } + + // Request issuing new tokens. + issuedTokens := &token.IssuedTokens{} + _, err = makeClientRequest(&clientRequestOptions{ //nolint:bodyclose // Body is closed in function. + method: http.MethodPost, + url: AccountServer + TokenRequestIssuePath, + send: tokenRequest, + recv: issuedTokens, + dataFormat: dsd.MsgPack, + setAuthToken: true, + logoutOnAuthError: true, + }) + if err != nil { + return fmt.Errorf("failed to request tokens: %w", err) + } + + // Save tokens to handlers. + err = token.ProcessIssuedTokens(issuedTokens) + if err != nil { + return fmt.Errorf("failed to process issued tokens: %w", err) + } + + // Log new status. + regular, fallback := GetTokenAmount(ExpandAndConnectZones) + log.Infof( + "spn/access: got new tokens, now at %d regular and %d fallback tokens for expand and connect", + regular, + fallback, + ) + + return nil +} + +var ( + lastHealthCheckExpires time.Time + lastHealthCheckLock sync.Mutex + lastHealthCheckValidityDuration = 30 * time.Second +) + +func healthCheck() (ok bool) { + lastHealthCheckLock.Lock() + defer lastHealthCheckLock.Unlock() + + // Return current value if recently checked. + if time.Now().Before(lastHealthCheckExpires) { + return tokenIssuerIsFailing.IsNotSet() + } + + // Check health. + _, err := makeClientRequest(&clientRequestOptions{ //nolint:bodyclose // Body is closed in function. + method: http.MethodGet, + url: AccountServer + HealthCheckPath, + }) + if err != nil { + log.Warningf("spn/access: token issuer health check failed: %s", err) + } + // Update health check expiry. + lastHealthCheckExpires = time.Now().Add(lastHealthCheckValidityDuration) + + return tokenIssuerIsFailing.IsNotSet() +} diff --git a/spn/access/client_test.go b/spn/access/client_test.go new file mode 100644 index 00000000..93c5e81e --- /dev/null +++ b/spn/access/client_test.go @@ -0,0 +1,79 @@ +package access + +import ( + "os" + "testing" +) + +var ( + testUsername = os.Getenv("SPN_TEST_USERNAME") + testPassword = os.Getenv("SPN_TEST_PASSWORD") +) + +func TestClient(t *testing.T) { + // Skip test in CI. + if testing.Short() { + t.Skip() + } + t.Parallel() + + if testUsername == "" || testPassword == "" { + t.Fatal("test username or password not configured") + } + + loginAndRefresh(t, true, 5) + clearUserCaches() + loginAndRefresh(t, false, 1) + + err := Logout(false, false) + if err != nil { + t.Fatalf("failed to log out: %s", err) + } + t.Logf("logged out") + + loginAndRefresh(t, true, 1) + + err = Logout(false, true) + if err != nil { + t.Fatalf("failed to log out: %s", err) + } + t.Logf("logged out with purge") + + loginAndRefresh(t, true, 1) +} + +func loginAndRefresh(t *testing.T, doLogin bool, refreshTimes int) { + t.Helper() + + if doLogin { + _, _, err := Login(testUsername, testPassword) + if err != nil { + t.Fatalf("login failed: %s", err) + } + user, err := GetUser() + if err != nil { + t.Fatalf("failed to get user: %s", err) + } + t.Logf("user (from login): %+v", user.User) + t.Logf("device (from login): %+v", user.User.Device) + authToken, err := GetAuthToken() + if err != nil { + t.Fatalf("failed to get auth token: %s", err) + } + t.Logf("auth token: %+v", authToken.Token) + } + + for i := 0; i < refreshTimes; i++ { + user, _, err := UpdateUser() + if err != nil { + t.Fatalf("getting profile failed: %s", err) + } + t.Logf("user (from refresh): %+v", user.User) + + authToken, err := GetAuthToken() + if err != nil { + t.Fatalf("failed to get auth token: %s", err) + } + t.Logf("auth token: %+v", authToken.Token) + } +} diff --git a/spn/access/database.go b/spn/access/database.go new file mode 100644 index 00000000..be5ea95a --- /dev/null +++ b/spn/access/database.go @@ -0,0 +1,258 @@ +package access + +import ( + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/record" + "github.com/safing/portmaster/spn/access/account" +) + +const ( + userRecordKey = "core:spn/account/user" + authTokenRecordKey = "core:spn/account/authtoken" //nolint:gosec // Not a credential. + tokenStorageKeyTemplate = "core:spn/account/tokens/%s" //nolint:gosec // Not a credential. +) + +var db = database.NewInterface(&database.Options{ + Local: true, + Internal: true, +}) + +// UserRecord holds a SPN user account. +type UserRecord struct { + record.Base + sync.Mutex + + *account.User + + LastNotifiedOfEnd *time.Time + LoggedInAt *time.Time +} + +// MayUseSPN returns whether the user may currently use the SPN. +func (user *UserRecord) MayUseSPN() bool { + // Shadow this function in order to allow calls on a nil user. + if user == nil || user.User == nil { + return false + } + return user.User.MayUseSPN() +} + +// MayUsePrioritySupport returns whether the user may currently use the priority support. +func (user *UserRecord) MayUsePrioritySupport() bool { + // Shadow this function in order to allow calls on a nil user. + if user == nil || user.User == nil { + return false + } + return user.User.MayUsePrioritySupport() +} + +// MayUse returns whether the user may currently use the feature identified by +// the given feature ID. +// Leave feature ID empty to check without feature. +func (user *UserRecord) MayUse(featureID account.FeatureID) bool { + // Shadow this function in order to allow calls on a nil user. + if user == nil || user.User == nil { + return false + } + return user.User.MayUse(featureID) +} + +// AuthTokenRecord holds an authentication token. +type AuthTokenRecord struct { + record.Base + sync.Mutex + + Token *account.AuthToken +} + +// GetToken returns the token from the record. +func (authToken *AuthTokenRecord) GetToken() *account.AuthToken { + authToken.Lock() + defer authToken.Unlock() + + return authToken.Token +} + +// SaveNewAuthToken saves a new auth token to the database. +func SaveNewAuthToken(deviceID string, resp *http.Response) error { + token, ok := account.GetNextTokenFromResponse(resp) + if !ok { + return account.ErrMissingToken + } + + newAuthToken := &AuthTokenRecord{ + Token: &account.AuthToken{ + Device: deviceID, + Token: token, + }, + } + return newAuthToken.Save() +} + +// Update updates an existing auth token with the next token from a response. +func (authToken *AuthTokenRecord) Update(resp *http.Response) error { + token, ok := account.GetNextTokenFromResponse(resp) + if !ok { + return account.ErrMissingToken + } + + // Update token with new account.AuthToken. + func() { + authToken.Lock() + defer authToken.Unlock() + + authToken.Token = &account.AuthToken{ + Device: authToken.Token.Device, + Token: token, + } + }() + + return authToken.Save() +} + +var ( + accountCacheLock sync.Mutex + + cachedUser *UserRecord + cachedUserSet bool + + cachedAuthToken *AuthTokenRecord +) + +func clearUserCaches() { + accountCacheLock.Lock() + defer accountCacheLock.Unlock() + + cachedUser = nil + cachedUserSet = false + cachedAuthToken = nil +} + +// GetUser returns the current user account. +// Returns nil when no user is logged in. +func GetUser() (*UserRecord, error) { + // Check cache. + accountCacheLock.Lock() + defer accountCacheLock.Unlock() + if cachedUserSet { + if cachedUser == nil { + return nil, ErrNotLoggedIn + } + return cachedUser, nil + } + + // Load from disk. + r, err := db.Get(userRecordKey) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + cachedUser = nil + cachedUserSet = true + return nil, ErrNotLoggedIn + } + return nil, err + } + + // Unwrap record. + if r.IsWrapped() { + // only allocate a new struct, if we need it + newUser := &UserRecord{} + err = record.Unwrap(r, newUser) + if err != nil { + return nil, err + } + cachedUser = newUser + cachedUserSet = true + return cachedUser, nil + } + + // Or adjust type. + newUser, ok := r.(*UserRecord) + if !ok { + return nil, fmt.Errorf("record not of type *UserRecord, but %T", r) + } + cachedUser = newUser + cachedUserSet = true + return cachedUser, nil +} + +// Save saves the User. +func (user *UserRecord) Save() error { + // Update cache. + accountCacheLock.Lock() + defer accountCacheLock.Unlock() + cachedUser = user + cachedUserSet = true + + // Update view if unset. + if user.View == nil { + user.UpdateView(0) + } + + // Set, check and update metadata. + if !user.KeyIsSet() { + user.SetKey(userRecordKey) + } + user.UpdateMeta() + + return db.Put(user) +} + +// GetAuthToken returns the current auth token. +func GetAuthToken() (*AuthTokenRecord, error) { + // Check cache. + accountCacheLock.Lock() + defer accountCacheLock.Unlock() + if cachedAuthToken != nil { + return cachedAuthToken, nil + } + + // Load from disk. + r, err := db.Get(authTokenRecordKey) + if err != nil { + return nil, err + } + + // Unwrap record. + if r.IsWrapped() { + // only allocate a new struct, if we need it + newAuthRecord := &AuthTokenRecord{} + err = record.Unwrap(r, newAuthRecord) + if err != nil { + return nil, err + } + cachedAuthToken = newAuthRecord + return newAuthRecord, nil + } + + // Or adjust type. + newAuthRecord, ok := r.(*AuthTokenRecord) + if !ok { + return nil, fmt.Errorf("record not of type *AuthTokenRecord, but %T", r) + } + cachedAuthToken = newAuthRecord + return newAuthRecord, nil +} + +// Save saves the auth token to the database. +func (authToken *AuthTokenRecord) Save() error { + // Update cache. + accountCacheLock.Lock() + defer accountCacheLock.Unlock() + cachedAuthToken = authToken + + // Set, check and update metadata. + if !authToken.KeyIsSet() { + authToken.SetKey(authTokenRecordKey) + } + authToken.UpdateMeta() + authToken.Meta().MakeSecret() + authToken.Meta().MakeCrownJewel() + + return db.Put(authToken) +} diff --git a/spn/access/features.go b/spn/access/features.go new file mode 100644 index 00000000..a26805e1 --- /dev/null +++ b/spn/access/features.go @@ -0,0 +1,127 @@ +package access + +import "github.com/safing/portmaster/spn/access/account" + +// Feature describes a notable part of the program. +type Feature struct { + Name string + ID string + RequiredFeatureID account.FeatureID + ConfigKey string + ConfigScope string + InPackage *Package + Comment string + Beta bool + ComingSoon bool + icon string +} + +// Package combines a set of features. +type Package struct { + Name string + HexColor string + InfoURL string +} + +var ( + infoURL = "https://safing.io/pricing/" + packageFree = &Package{ + Name: "Free", + HexColor: "#ffffff", + InfoURL: infoURL, + } + packagePlus = &Package{ + Name: "Plus", + HexColor: "#2fcfae", + InfoURL: infoURL, + } + packagePro = &Package{ + Name: "Pro", + HexColor: "#029ad0", + InfoURL: infoURL, + } + features = []Feature{ + { + Name: "Secure DNS", + ID: "dns", + ConfigScope: "dns/", + InPackage: packageFree, + icon: ` + + + + `, + }, + { + Name: "Privacy Filter", + ID: "filter", + ConfigScope: "filter/", + InPackage: packageFree, + icon: ` + + + + `, + }, + { + Name: "Network History", + ID: string(account.FeatureHistory), + RequiredFeatureID: account.FeatureHistory, + ConfigKey: "history/enable", + ConfigScope: "history/", + InPackage: packagePlus, + icon: ` + + + + `, + }, + { + Name: "Bandwidth Visibility", + ID: string(account.FeatureBWVis), + RequiredFeatureID: account.FeatureBWVis, + InPackage: packagePlus, + Beta: true, + icon: ` + + + + `, + }, + { + Name: "Safing Support", + ID: string(account.FeatureSafingSupport), + RequiredFeatureID: account.FeatureSafingSupport, + InPackage: packagePlus, + icon: ` + + + + `, + }, + { + Name: "Safing Privacy Network", + ID: string(account.FeatureSPN), + RequiredFeatureID: account.FeatureSPN, + ConfigKey: "spn/enable", + ConfigScope: "spn/", + InPackage: packagePro, + icon: ` + + + + + + + + + `, + }, + } +) diff --git a/spn/access/module.go b/spn/access/module.go new file mode 100644 index 00000000..3f935f33 --- /dev/null +++ b/spn/access/module.go @@ -0,0 +1,194 @@ +package access + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/access/account" + "github.com/safing/portmaster/spn/access/token" + "github.com/safing/portmaster/spn/conf" +) + +var ( + module *modules.Module + + accountUpdateTask *modules.Task + + tokenIssuerIsFailing = abool.New() + tokenIssuerRetryDuration = 10 * time.Minute + + // AccountUpdateEvent is fired when the account has changed in any way. + AccountUpdateEvent = "account update" +) + +// Errors. +var ( + ErrDeviceIsLocked = errors.New("device is locked") + ErrDeviceLimitReached = errors.New("device limit reached") + ErrFallbackNotAvailable = errors.New("fallback tokens not available, token issuer is online") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrMayNotUseSPN = errors.New("may not use SPN") + ErrNotLoggedIn = errors.New("not logged in") +) + +func init() { + module = modules.Register("access", prep, start, stop, "terminal") +} + +func prep() error { + module.RegisterEvent(AccountUpdateEvent, true) + + // Register API handlers. + if conf.Client() { + err := registerAPIEndpoints() + if err != nil { + return err + } + } + + return nil +} + +func start() error { + // Initialize zones. + if err := InitializeZones(); err != nil { + return err + } + + if conf.Client() { + // Load tokens from database. + loadTokens() + + // Register new task. + accountUpdateTask = module.NewTask( + "update account", + UpdateAccount, + ).Repeat(24 * time.Hour).Schedule(time.Now().Add(1 * time.Minute)) + } + + return nil +} + +func stop() error { + if conf.Client() { + // Stop account update task. + accountUpdateTask.Cancel() + accountUpdateTask = nil + + // Store tokens to database. + storeTokens() + } + + // Reset zones. + token.ResetRegistry() + + return nil +} + +// UpdateAccount updates the user account and fetches new tokens, if needed. +func UpdateAccount(_ context.Context, task *modules.Task) error { + // Retry sooner if the token issuer is failing. + defer func() { + if tokenIssuerIsFailing.IsSet() && task != nil { + task.Schedule(time.Now().Add(tokenIssuerRetryDuration)) + } + }() + + // Get current user. + u, err := GetUser() + if err == nil { + // Do not update if we just updated. + if time.Since(time.Unix(u.Meta().Modified, 0)) < 2*time.Minute { + return nil + } + } + + u, _, err = UpdateUser() + if err != nil { + return fmt.Errorf("failed to update user profile: %w", err) + } + + err = UpdateTokens() + if err != nil { + return fmt.Errorf("failed to get tokens: %w", err) + } + + // Schedule next check. + switch { + case u == nil: // No user. + case u.Subscription == nil: // No subscription. + case u.Subscription.EndsAt == nil: // Subscription not active + + case time.Until(*u.Subscription.EndsAt) < 24*time.Hour && + time.Since(*u.Subscription.EndsAt) < 24*time.Hour: + // Update account every hour 24h hours before and after the subscription ends. + task.Schedule(time.Now().Add(time.Hour)) + + case u.Subscription.NextBillingDate == nil: // No auto-subscription. + + case time.Until(*u.Subscription.NextBillingDate) < 24*time.Hour && + time.Since(*u.Subscription.NextBillingDate) < 24*time.Hour: + // Update account every hour 24h hours before and after the next billing date. + task.Schedule(time.Now().Add(time.Hour)) + } + + return nil +} + +func enableSPN() { + err := config.SetConfigOption("spn/enable", true) + if err != nil { + log.Warningf("spn/access: failed to enable the SPN during login: %s", err) + } +} + +func disableSPN() { + err := config.SetConfigOption("spn/enable", false) + if err != nil { + log.Warningf("spn/access: failed to disable the SPN during logout: %s", err) + } +} + +// TokenIssuerIsFailing returns whether token issuing is currently failing. +func TokenIssuerIsFailing() bool { + return tokenIssuerIsFailing.IsSet() +} + +func tokenIssuerFailed() { + if !tokenIssuerIsFailing.SetToIf(false, true) { + return + } + if !module.Online() { + return + } + + accountUpdateTask.Schedule(time.Now().Add(tokenIssuerRetryDuration)) +} + +// IsLoggedIn returns whether a User is currently logged in. +func (user *UserRecord) IsLoggedIn() bool { + user.Lock() + defer user.Unlock() + + switch user.State { + case account.UserStateNone, account.UserStateLoggedOut: + return false + default: + return true + } +} + +// MayUseTheSPN returns whether the currently logged in User may use the SPN. +func (user *UserRecord) MayUseTheSPN() bool { + user.Lock() + defer user.Unlock() + + return user.User.MayUseSPN() +} diff --git a/spn/access/module_test.go b/spn/access/module_test.go new file mode 100644 index 00000000..59d69be6 --- /dev/null +++ b/spn/access/module_test.go @@ -0,0 +1,13 @@ +package access + +import ( + "testing" + + "github.com/safing/portmaster/service/core/pmtesting" + "github.com/safing/portmaster/spn/conf" +) + +func TestMain(m *testing.M) { + conf.EnableClient(true) + pmtesting.TestMain(m, module) +} diff --git a/spn/access/notify.go b/spn/access/notify.go new file mode 100644 index 00000000..978a2f16 --- /dev/null +++ b/spn/access/notify.go @@ -0,0 +1,105 @@ +package access + +import ( + "fmt" + "strings" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/notifications" +) + +const ( + day = 24 * time.Hour + week = 7 * day + + endOfPackageNearNotifID = "access:end-of-package-near" +) + +func notifyOfPackageEnd(u *UserRecord) { + // TODO: Check if subscription auto-renews. + + // Skip if there is not active subscription or if it has ended already. + switch { + case u.Subscription == nil, // No subscription. + u.Subscription.EndsAt == nil, // Subscription not active. + u.Subscription.NextBillingDate != nil, // Subscription is auto-renewing. + time.Now().After(*u.Subscription.EndsAt): // Subscription has ended. + return + } + + // Calculate durations. + sinceLastNotified := 52 * week // Never. + if u.LastNotifiedOfEnd != nil { + sinceLastNotified = time.Since(*u.LastNotifiedOfEnd) + } + untilEnd := time.Until(*u.Subscription.EndsAt) + + // Notify every two days in the week before end. + notifType := notifications.Info + switch { + case untilEnd < week && sinceLastNotified > 2*day: + // Notify 7, 5, 3 and 1 days before end. + if untilEnd < 4*day { + notifType = notifications.Warning + } + fallthrough + + case u.CurrentPlan != nil && u.CurrentPlan.Months >= 6 && + untilEnd < 4*week && sinceLastNotified > week: + // Notify 4, 3 and 2 weeks before end - on long running packages. + + // Get names and messages. + packageNameTitle := "Portmaster Package" + if u.CurrentPlan != nil { + packageNameTitle = u.CurrentPlan.Name + } + packageNameBody := packageNameTitle + if !strings.HasSuffix(packageNameBody, " Package") { + packageNameBody += " Package" + } + + var endsText string + daysUntilEnd := untilEnd / day + switch daysUntilEnd { //nolint:exhaustive + case 0: + endsText = "today" + case 1: + endsText = "tomorrow" + default: + endsText = fmt.Sprintf("in %d days", daysUntilEnd) + } + + // Send notification. + notifications.Notify(¬ifications.Notification{ + EventID: endOfPackageNearNotifID, + Type: notifType, + Title: fmt.Sprintf("%s About to Expire", packageNameTitle), + Message: fmt.Sprintf( + "Your current %s ends %s. Extend it to keep your full privacy protections.", + packageNameBody, + endsText, + ), + ShowOnSystem: notifType == notifications.Warning, + AvailableActions: []*notifications.Action{ + { + Text: "Open Account Page", + Type: notifications.ActionTypeOpenURL, + Payload: "https://account.safing.io", + }, + { + ID: "ack", + Text: "Got it!", + }, + }, + }) + + // Save that we sent a notification. + now := time.Now() + u.LastNotifiedOfEnd = &now + err := u.Save() + if err != nil { + log.Warningf("spn/access: failed to save user after sending subscription ending soon notification: %s", err) + } + } +} diff --git a/spn/access/op_auth.go b/spn/access/op_auth.go new file mode 100644 index 00000000..764c73c3 --- /dev/null +++ b/spn/access/op_auth.go @@ -0,0 +1,75 @@ +package access + +import ( + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/access/token" + "github.com/safing/portmaster/spn/terminal" +) + +// OpTypeAccessCodeAuth is the type ID of the auth operation. +const OpTypeAccessCodeAuth = "auth" + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: OpTypeAccessCodeAuth, + Start: checkAccessCode, + }) +} + +// AuthorizeOp is used to authorize a session. +type AuthorizeOp struct { + terminal.OneOffOperationBase +} + +// Type returns the type ID. +func (op *AuthorizeOp) Type() string { + return OpTypeAccessCodeAuth +} + +// AuthorizeToTerminal starts an authorization operation. +func AuthorizeToTerminal(t terminal.Terminal) (*AuthorizeOp, *terminal.Error) { + op := &AuthorizeOp{} + op.Init() + + newToken, err := GetToken(ExpandAndConnectZones) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to get access token: %w", err) + } + + tErr := t.StartOperation(op, container.New(newToken.Raw()), 10*time.Second) + if tErr != nil { + return nil, terminal.ErrInternalError.With("failed to init auth op: %w", tErr) + } + + return op, nil +} + +func checkAccessCode(t terminal.Terminal, opID uint32, initData *container.Container) (terminal.Operation, *terminal.Error) { + // Parse provided access token. + receivedToken, err := token.ParseRawToken(initData.CompileData()) + if err != nil { + return nil, terminal.ErrMalformedData.With("failed to parse access token: %w", err) + } + + // Check if token is valid. + granted, err := VerifyToken(receivedToken) + if err != nil { + return nil, terminal.ErrPermissionDenied.With("invalid access token: %w", err) + } + + // Get the authorizing terminal for applying the granted permission. + authTerm, ok := t.(terminal.AuthorizingTerminal) + if !ok { + return nil, terminal.ErrIncorrectUsage.With("terminal does not handle authorization") + } + + // Grant permissions. + authTerm.GrantPermission(granted) + log.Debugf("spn/access: granted %s permissions via %s zone", t.FmtID(), receivedToken.Zone) + + // End successfully. + return nil, terminal.ErrExplicitAck +} diff --git a/spn/access/storage.go b/spn/access/storage.go new file mode 100644 index 00000000..fcbb7edc --- /dev/null +++ b/spn/access/storage.go @@ -0,0 +1,131 @@ +package access + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/access/token" +) + +func loadTokens() { + for _, zone := range persistentZones { + // Get handler of zone. + handler, ok := token.GetHandler(zone) + if !ok { + log.Warningf("spn/access: could not find zone %s for loading tokens", zone) + continue + } + + // Get data from database. + r, err := db.Get(fmt.Sprintf(tokenStorageKeyTemplate, zone)) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + log.Debugf("spn/access: no %s tokens to load", zone) + } else { + log.Warningf("spn/access: failed to load %s tokens: %s", zone, err) + } + continue + } + + // Get wrapper. + wrapper, ok := r.(*record.Wrapper) + if !ok { + log.Warningf("spn/access: failed to parse %s tokens: expected wrapper, got %T", zone, r) + continue + } + + // Load into handler. + err = handler.Load(wrapper.Data) + if err != nil { + log.Warningf("spn/access: failed to load %s tokens: %s", zone, err) + } + log.Infof("spn/access: loaded %d %s tokens", handler.Amount(), zone) + } +} + +func storeTokens() { + for _, zone := range persistentZones { + // Get handler of zone. + handler, ok := token.GetHandler(zone) + if !ok { + log.Warningf("spn/access: could not find zone %s for storing tokens", zone) + continue + } + + // Generate storage key. + storageKey := fmt.Sprintf(tokenStorageKeyTemplate, zone) + + // Check if there is data to save. + amount := handler.Amount() + if amount == 0 { + // Remove possible old entry from database. + err := db.Delete(storageKey) + if err != nil { + log.Warningf("spn/access: failed to delete possible old %s tokens from storage: %s", zone, err) + } + log.Debugf("spn/access: no %s tokens to store", zone) + continue + } + + // Export data. + data, err := handler.Save() + if err != nil { + log.Warningf("spn/access: failed to export %s tokens for storing: %s", zone, err) + continue + } + + // Wrap data into raw record. + r, err := record.NewWrapper(storageKey, nil, dsd.RAW, data) + if err != nil { + log.Warningf("spn/access: failed to prepare %s token export for storing: %s", zone, err) + continue + } + + // Let tokens expire after one month. + // This will regularly happen when we switch zones. + r.UpdateMeta() + r.Meta().MakeSecret() + r.Meta().MakeCrownJewel() + r.Meta().SetRelativateExpiry(30 * 86400) + + // Save to database. + err = db.Put(r) + if err != nil { + log.Warningf("spn/access: failed to store %s tokens: %s", zone, err) + continue + } + + log.Infof("spn/access: stored %d %s tokens", amount, zone) + } +} + +func clearTokens() { + for _, zone := range persistentZones { + // Get handler of zone. + handler, ok := token.GetHandler(zone) + if !ok { + log.Warningf("spn/access: could not find zone %s for clearing tokens", zone) + continue + } + + // Clear tokens. + handler.Clear() + } + + // Purge database storage prefix. + ctx, cancel := context.WithTimeout(module.Ctx, 10*time.Second) + defer cancel() + n, err := db.Purge(ctx, query.New(fmt.Sprintf(tokenStorageKeyTemplate, ""))) + if err != nil { + log.Warningf("spn/access: failed to clear token storages: %s", err) + return + } + log.Infof("spn/access: cleared %d token storages", n) +} diff --git a/spn/access/token/errors.go b/spn/access/token/errors.go new file mode 100644 index 00000000..b19fbb28 --- /dev/null +++ b/spn/access/token/errors.go @@ -0,0 +1,15 @@ +package token + +import "errors" + +// Errors. +var ( + ErrEmpty = errors.New("token storage is empty") + ErrNoZone = errors.New("no zone specified") + ErrTokenInvalid = errors.New("token is invalid") + ErrTokenMalformed = errors.New("token malformed") + ErrTokenUsed = errors.New("token already used") + ErrZoneMismatch = errors.New("zone mismatch") + ErrZoneTaken = errors.New("zone taken") + ErrZoneUnknown = errors.New("zone unknown") +) diff --git a/spn/access/token/module_test.go b/spn/access/token/module_test.go new file mode 100644 index 00000000..bb79d76f --- /dev/null +++ b/spn/access/token/module_test.go @@ -0,0 +1,13 @@ +package token + +import ( + "testing" + + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/service/core/pmtesting" +) + +func TestMain(m *testing.M) { + module := modules.Register("token", nil, nil, nil, "rng") + pmtesting.TestMain(m, module) +} diff --git a/spn/access/token/pblind.go b/spn/access/token/pblind.go new file mode 100644 index 00000000..71f137a3 --- /dev/null +++ b/spn/access/token/pblind.go @@ -0,0 +1,552 @@ +package token + +import ( + "crypto/elliptic" + "crypto/rand" + "errors" + "fmt" + "math" + "math/big" + mrand "math/rand" + "sync" + + "github.com/mr-tron/base58" + "github.com/rot256/pblind" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" +) + +const pblindSecretSize = 32 + +// PBlindToken is token based on the pblind library. +type PBlindToken struct { + Serial int `json:"N,omitempty"` + Token []byte `json:"T,omitempty"` + Signature *pblind.Signature `json:"S,omitempty"` +} + +// Pack packs the token. +func (pbt *PBlindToken) Pack() ([]byte, error) { + return dsd.Dump(pbt, dsd.CBOR) +} + +// UnpackPBlindToken unpacks the token. +func UnpackPBlindToken(token []byte) (*PBlindToken, error) { + t := &PBlindToken{} + + _, err := dsd.Load(token, t) + if err != nil { + return nil, err + } + + return t, nil +} + +// PBlindHandler is a handler for the pblind tokens. +type PBlindHandler struct { + sync.Mutex + opts *PBlindOptions + + publicKey *pblind.PublicKey + privateKey *pblind.SecretKey + + storageLock sync.Mutex + Storage []*PBlindToken + + // Client request state. + requestStateLock sync.Mutex + requestState []RequestState +} + +// PBlindOptions are options for the PBlindHandler. +type PBlindOptions struct { + Zone string + CurveName string + Curve elliptic.Curve + PublicKey string + PrivateKey string + BatchSize int + UseSerials bool + RandomizeOrder bool + Fallback bool + SignalShouldRequest func(Handler) + DoubleSpendProtection func([]byte) error +} + +// PBlindSignerState is a signer state. +type PBlindSignerState struct { + signers []*pblind.StateSigner +} + +// PBlindSetupResponse is a setup response. +type PBlindSetupResponse struct { + Msgs []*pblind.Message1 +} + +// PBlindTokenRequest is a token request. +type PBlindTokenRequest struct { + Msgs []*pblind.Message2 +} + +// IssuedPBlindTokens are issued pblind tokens. +type IssuedPBlindTokens struct { + Msgs []*pblind.Message3 +} + +// RequestState is a request state. +type RequestState struct { + Token []byte + State *pblind.StateRequester +} + +// NewPBlindHandler creates a new pblind handler. +func NewPBlindHandler(opts PBlindOptions) (*PBlindHandler, error) { + pbh := &PBlindHandler{ + opts: &opts, + } + + // Check curve, get from name. + if opts.Curve == nil { + switch opts.CurveName { + case "P-256": + opts.Curve = elliptic.P256() + case "P-384": + opts.Curve = elliptic.P384() + case "P-521": + opts.Curve = elliptic.P521() + default: + return nil, errors.New("no curve supplied") + } + } else if opts.CurveName != "" { + return nil, errors.New("both curve and curve name supplied") + } + + // Load keys. + switch { + case pbh.opts.PrivateKey != "": + keyData, err := base58.Decode(pbh.opts.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to decode private key: %w", err) + } + pivateKey := pblind.SecretKeyFromBytes(pbh.opts.Curve, keyData) + pbh.privateKey = &pivateKey + publicKey := pbh.privateKey.GetPublicKey() + pbh.publicKey = &publicKey + + // Check public key if also provided. + if pbh.opts.PublicKey != "" { + if pbh.opts.PublicKey != base58.Encode(pbh.publicKey.Bytes()) { + return nil, errors.New("private and public mismatch") + } + } + + case pbh.opts.PublicKey != "": + keyData, err := base58.Decode(pbh.opts.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to decode public key: %w", err) + } + publicKey, err := pblind.PublicKeyFromBytes(pbh.opts.Curve, keyData) + if err != nil { + return nil, fmt.Errorf("failed to decode public key: %w", err) + } + pbh.publicKey = &publicKey + + default: + return nil, errors.New("no key supplied") + } + + return pbh, nil +} + +func (pbh *PBlindHandler) makeInfo(serial int) (*pblind.Info, error) { + // Gather data for info. + infoData := container.New() + infoData.AppendAsBlock([]byte(pbh.opts.Zone)) + if pbh.opts.UseSerials { + infoData.AppendInt(serial) + } + + // Compress to point. + info, err := pblind.CompressInfo(pbh.opts.Curve, infoData.CompileData()) + if err != nil { + return nil, fmt.Errorf("failed to compress info: %w", err) + } + + return &info, nil +} + +// Zone returns the zone name. +func (pbh *PBlindHandler) Zone() string { + return pbh.opts.Zone +} + +// ShouldRequest returns whether the new tokens should be requested. +func (pbh *PBlindHandler) ShouldRequest() bool { + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + return pbh.shouldRequest() +} + +func (pbh *PBlindHandler) shouldRequest() bool { + // Return true if storage is at or below 10%. + return len(pbh.Storage) == 0 || pbh.opts.BatchSize/len(pbh.Storage) > 10 +} + +// Amount returns the current amount of tokens in this handler. +func (pbh *PBlindHandler) Amount() int { + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + return len(pbh.Storage) +} + +// IsFallback returns whether this handler should only be used as a fallback. +func (pbh *PBlindHandler) IsFallback() bool { + return pbh.opts.Fallback +} + +// CreateSetup sets up signers for a request. +func (pbh *PBlindHandler) CreateSetup() (state *PBlindSignerState, setupResponse *PBlindSetupResponse, err error) { + state = &PBlindSignerState{ + signers: make([]*pblind.StateSigner, pbh.opts.BatchSize), + } + setupResponse = &PBlindSetupResponse{ + Msgs: make([]*pblind.Message1, pbh.opts.BatchSize), + } + + // Go through the batch. + for i := 0; i < pbh.opts.BatchSize; i++ { + info, err := pbh.makeInfo(i + 1) + if err != nil { + return nil, nil, fmt.Errorf("failed to create info #%d: %w", i, err) + } + + // Create signer. + signer, err := pblind.CreateSigner(*pbh.privateKey, *info) + if err != nil { + return nil, nil, fmt.Errorf("failed to create signer #%d: %w", i, err) + } + state.signers[i] = signer + + // Create request setup. + setupMsg, err := signer.CreateMessage1() + if err != nil { + return nil, nil, fmt.Errorf("failed to create setup msg #%d: %w", i, err) + } + setupResponse.Msgs[i] = &setupMsg + } + + return state, setupResponse, nil +} + +// CreateTokenRequest creates a token request to be sent to the token server. +func (pbh *PBlindHandler) CreateTokenRequest(requestSetup *PBlindSetupResponse) (request *PBlindTokenRequest, err error) { + // Check request setup data. + if len(requestSetup.Msgs) != pbh.opts.BatchSize { + return nil, fmt.Errorf("invalid request setup msg count of %d", len(requestSetup.Msgs)) + } + + // Lock and reset the request state. + pbh.requestStateLock.Lock() + defer pbh.requestStateLock.Unlock() + pbh.requestState = make([]RequestState, pbh.opts.BatchSize) + request = &PBlindTokenRequest{ + Msgs: make([]*pblind.Message2, pbh.opts.BatchSize), + } + + // Go through the batch. + for i := 0; i < pbh.opts.BatchSize; i++ { + // Check if we have setup data. + if requestSetup.Msgs[i] == nil { + return nil, fmt.Errorf("missing setup data #%d", i) + } + + // Generate secret token. + token := make([]byte, pblindSecretSize) + n, err := rand.Read(token) //nolint:gosec // False positive - check the imports. + if err != nil { + return nil, fmt.Errorf("failed to get random token #%d: %w", i, err) + } + if n != pblindSecretSize { + return nil, fmt.Errorf("failed to get full random token #%d: only got %d bytes", i, n) + } + pbh.requestState[i].Token = token + + // Create public metadata. + info, err := pbh.makeInfo(i + 1) + if err != nil { + return nil, fmt.Errorf("failed to make token info #%d: %w", i, err) + } + + // Create request and request state. + requester, err := pblind.CreateRequester(*pbh.publicKey, *info, token) + if err != nil { + return nil, fmt.Errorf("failed to create request state #%d: %w", i, err) + } + pbh.requestState[i].State = requester + + err = requester.ProcessMessage1(*requestSetup.Msgs[i]) + if err != nil { + return nil, fmt.Errorf("failed to process setup message #%d: %w", i, err) + } + + // Create request message. + requestMsg, err := requester.CreateMessage2() + if err != nil { + return nil, fmt.Errorf("failed to create request message #%d: %w", i, err) + } + request.Msgs[i] = &requestMsg + } + + return request, nil +} + +// IssueTokens sign the requested tokens. +func (pbh *PBlindHandler) IssueTokens(state *PBlindSignerState, request *PBlindTokenRequest) (response *IssuedPBlindTokens, err error) { + // Check request data. + if len(request.Msgs) != pbh.opts.BatchSize { + return nil, fmt.Errorf("invalid request msg count of %d", len(request.Msgs)) + } + if len(state.signers) != pbh.opts.BatchSize { + return nil, fmt.Errorf("invalid request state count of %d", len(request.Msgs)) + } + + // Create response. + response = &IssuedPBlindTokens{ + Msgs: make([]*pblind.Message3, pbh.opts.BatchSize), + } + + // Go through the batch. + for i := 0; i < pbh.opts.BatchSize; i++ { + // Check if we have request data. + if request.Msgs[i] == nil { + return nil, fmt.Errorf("missing request data #%d", i) + } + + // Process request msg. + err = state.signers[i].ProcessMessage2(*request.Msgs[i]) + if err != nil { + return nil, fmt.Errorf("failed to process request msg #%d: %w", i, err) + } + + // Issue token. + responseMsg, err := state.signers[i].CreateMessage3() + if err != nil { + return nil, fmt.Errorf("failed to issue token #%d: %w", i, err) + } + response.Msgs[i] = &responseMsg + } + + return response, nil +} + +// ProcessIssuedTokens processes the issued token from the server. +func (pbh *PBlindHandler) ProcessIssuedTokens(issuedTokens *IssuedPBlindTokens) error { + // Check data. + if len(issuedTokens.Msgs) != pbh.opts.BatchSize { + return fmt.Errorf("invalid issued token count of %d", len(issuedTokens.Msgs)) + } + + // Step 1: Process issued tokens. + + // Lock and reset the request state. + pbh.requestStateLock.Lock() + defer pbh.requestStateLock.Unlock() + defer func() { + pbh.requestState = make([]RequestState, pbh.opts.BatchSize) + }() + finalizedTokens := make([]*PBlindToken, pbh.opts.BatchSize) + + // Go through the batch. + for i := 0; i < pbh.opts.BatchSize; i++ { + // Finalize token. + err := pbh.requestState[i].State.ProcessMessage3(*issuedTokens.Msgs[i]) + if err != nil { + return fmt.Errorf("failed to create final signature #%d: %w", i, err) + } + + // Get and check final signature. + signature, err := pbh.requestState[i].State.Signature() + if err != nil { + return fmt.Errorf("failed to create final signature #%d: %w", i, err) + } + info, err := pbh.makeInfo(i + 1) + if err != nil { + return fmt.Errorf("failed to make token info #%d: %w", i, err) + } + if !pbh.publicKey.Check(signature, *info, pbh.requestState[i].Token) { + return fmt.Errorf("invalid signature on #%d", i) + } + + // Save to temporary slice. + newToken := &PBlindToken{ + Token: pbh.requestState[i].Token, + Signature: &signature, + } + if pbh.opts.UseSerials { + newToken.Serial = i + 1 + } + finalizedTokens[i] = newToken + } + + // Step 2: Randomize received tokens + + if pbh.opts.RandomizeOrder { + rInt, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + if err != nil { + return fmt.Errorf("failed to get seed for shuffle: %w", err) + } + mr := mrand.New(mrand.NewSource(rInt.Int64())) //nolint:gosec + mr.Shuffle(len(finalizedTokens), func(i, j int) { + finalizedTokens[i], finalizedTokens[j] = finalizedTokens[j], finalizedTokens[i] + }) + } + + // Step 3: Add tokens to storage. + + // Wait for all processing to be complete, as using tokens from a faulty + // batch can be dangerous, as the server could be doing this purposely to + // create conditions that may benefit an attacker. + + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + // Add finalized tokens to storage. + pbh.Storage = append(pbh.Storage, finalizedTokens...) + + return nil +} + +// GetToken returns a token. +func (pbh *PBlindHandler) GetToken() (token *Token, err error) { + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + // Check if we have supply. + if len(pbh.Storage) == 0 { + return nil, ErrEmpty + } + + // Pack token. + data, err := pbh.Storage[0].Pack() + if err != nil { + return nil, fmt.Errorf("failed to pack token: %w", err) + } + + // Shift to next token. + pbh.Storage = pbh.Storage[1:] + + // Check if we should signal that we should request tokens. + if pbh.opts.SignalShouldRequest != nil && pbh.shouldRequest() { + pbh.opts.SignalShouldRequest(pbh) + } + + return &Token{ + Zone: pbh.opts.Zone, + Data: data, + }, nil +} + +// Verify verifies the given token. +func (pbh *PBlindHandler) Verify(token *Token) error { + // Check if zone matches. + if token.Zone != pbh.opts.Zone { + return ErrZoneMismatch + } + + // Unpack token. + t, err := UnpackPBlindToken(token.Data) + if err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + + // Check if serial is valid. + switch { + case pbh.opts.UseSerials && t.Serial > 0 && t.Serial <= pbh.opts.BatchSize: + // Using serials in accepted range. + case !pbh.opts.UseSerials && t.Serial == 0: + // Not using serials and serial is zero. + default: + return fmt.Errorf("%w: invalid serial", ErrTokenMalformed) + } + + // Build info for checking signature. + info, err := pbh.makeInfo(t.Serial) + if err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + + // Check signature. + if !pbh.publicKey.Check(*t.Signature, *info, t.Token) { + return ErrTokenInvalid + } + + // Check for double spending. + if pbh.opts.DoubleSpendProtection != nil { + if err := pbh.opts.DoubleSpendProtection(t.Token); err != nil { + return fmt.Errorf("%w: %w", ErrTokenUsed, err) + } + } + + return nil +} + +// PBlindStorage is a storage for pblind tokens. +type PBlindStorage struct { + Storage []*PBlindToken +} + +// Save serializes and returns the current tokens. +func (pbh *PBlindHandler) Save() ([]byte, error) { + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + if len(pbh.Storage) == 0 { + return nil, ErrEmpty + } + + s := &PBlindStorage{ + Storage: pbh.Storage, + } + + return dsd.Dump(s, dsd.CBOR) +} + +// Load loads the given tokens into the handler. +func (pbh *PBlindHandler) Load(data []byte) error { + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + s := &PBlindStorage{} + _, err := dsd.Load(data, s) + if err != nil { + return err + } + + // Check signatures on load. + for _, t := range s.Storage { + // Build info for checking signature. + info, err := pbh.makeInfo(t.Serial) + if err != nil { + return err + } + + // Check signature. + if !pbh.publicKey.Check(*t.Signature, *info, t.Token) { + return ErrTokenInvalid + } + } + + pbh.Storage = s.Storage + return nil +} + +// Clear clears all the tokens in the handler. +func (pbh *PBlindHandler) Clear() { + pbh.storageLock.Lock() + defer pbh.storageLock.Unlock() + + pbh.Storage = nil +} diff --git a/spn/access/token/pblind_gen_test.go b/spn/access/token/pblind_gen_test.go new file mode 100644 index 00000000..416213ae --- /dev/null +++ b/spn/access/token/pblind_gen_test.go @@ -0,0 +1,39 @@ +package token + +import ( + "crypto/elliptic" + "fmt" + "testing" + + "github.com/mr-tron/base58" + "github.com/rot256/pblind" +) + +func TestGeneratePBlindKeys(t *testing.T) { + t.Parallel() + + for _, curve := range []elliptic.Curve{ + elliptic.P256(), + elliptic.P384(), + elliptic.P521(), + } { + privateKey, err := pblind.NewSecretKey(curve) + if err != nil { + t.Fatal(err) + } + publicKey := privateKey.GetPublicKey() + + fmt.Printf( + "%s (%dbit) private key: %s\n", + curve.Params().Name, + curve.Params().BitSize, + base58.Encode(privateKey.Bytes()), + ) + fmt.Printf( + "%s (%dbit) public key: %s\n", + curve.Params().Name, + curve.Params().BitSize, + base58.Encode(publicKey.Bytes()), + ) + } +} diff --git a/spn/access/token/pblind_test.go b/spn/access/token/pblind_test.go new file mode 100644 index 00000000..b25ac71b --- /dev/null +++ b/spn/access/token/pblind_test.go @@ -0,0 +1,260 @@ +package token + +import ( + "crypto/elliptic" + "encoding/asn1" + "testing" + "time" + + "github.com/rot256/pblind" +) + +const PBlindTestZone = "test-pblind" + +func init() { + // Combined testing config. + + h, err := NewPBlindHandler(PBlindOptions{ + Zone: PBlindTestZone, + Curve: elliptic.P256(), + PrivateKey: "HbwGtLsqek1Fdwuz1MhNQfiY7tj9EpWHeMWHPZ9c6KYY", + UseSerials: true, + BatchSize: 1000, + RandomizeOrder: true, + }) + if err != nil { + panic(err) + } + + err = RegisterPBlindHandler(h) + if err != nil { + panic(err) + } +} + +func TestPBlind(t *testing.T) { + t.Parallel() + + opts := &PBlindOptions{ + Zone: PBlindTestZone, + Curve: elliptic.P256(), + UseSerials: true, + BatchSize: 1000, + RandomizeOrder: true, + } + + // Issuer + opts.PrivateKey = "HbwGtLsqek1Fdwuz1MhNQfiY7tj9EpWHeMWHPZ9c6KYY" + issuer, err := NewPBlindHandler(*opts) + if err != nil { + t.Fatal(err) + } + + // Client + opts.PrivateKey = "" + opts.PublicKey = "285oMDh3w5mxyFgpmmURifKfhkcqwwsdnePpPZ6Nqm8cc" + client, err := NewPBlindHandler(*opts) + if err != nil { + t.Fatal(err) + } + + // Verifier + verifier, err := NewPBlindHandler(*opts) + if err != nil { + t.Fatal(err) + } + + // Play through the whole use case. + + signerState, setupResponse, err := issuer.CreateSetup() + if err != nil { + t.Fatal(err) + } + + request, err := client.CreateTokenRequest(setupResponse) + if err != nil { + t.Fatal(err) + } + + issuedTokens, err := issuer.IssueTokens(signerState, request) + if err != nil { + t.Fatal(err) + } + + err = client.ProcessIssuedTokens(issuedTokens) + if err != nil { + t.Fatal(err) + } + + token, err := client.GetToken() + if err != nil { + t.Fatal(err) + } + + err = verifier.Verify(token) + if err != nil { + t.Fatal(err) + } +} + +func TestPBlindLibrary(t *testing.T) { + t.Parallel() + + // generate a key-pair + + curve := elliptic.P256() + + sk, _ := pblind.NewSecretKey(curve) + pk := sk.GetPublicKey() + + msgStr := []byte("128b_accesstoken") + infoStr := []byte("v=1 serial=12345") + info, err := pblind.CompressInfo(curve, infoStr) + if err != nil { + t.Fatal(err) + } + + totalStart := time.Now() + batchSize := 1000 + + signers := make([]*pblind.StateSigner, batchSize) + requesters := make([]*pblind.StateRequester, batchSize) + toServer := make([][]byte, batchSize) + toClient := make([][]byte, batchSize) + + // Create signers and prep requests. + start := time.Now() + for i := 0; i < batchSize; i++ { + signer, err := pblind.CreateSigner(sk, info) + if err != nil { + t.Fatal(err) + } + signers[i] = signer + + msg1S, err := signer.CreateMessage1() + if err != nil { + t.Fatal(err) + } + ser1S, err := asn1.Marshal(msg1S) + if err != nil { + t.Fatal(err) + } + toClient[i] = ser1S + } + t.Logf("created %d signers and request preps in %s", batchSize, time.Since(start)) + t.Logf("sending %d bytes to client", lenOfByteSlices(toClient)) + + // Create requesters and create requests. + start = time.Now() + for i := 0; i < batchSize; i++ { + requester, err := pblind.CreateRequester(pk, info, msgStr) + if err != nil { + t.Fatal(err) + } + requesters[i] = requester + + var msg1R pblind.Message1 + _, err = asn1.Unmarshal(toClient[i], &msg1R) + if err != nil { + t.Fatal(err) + } + err = requester.ProcessMessage1(msg1R) + if err != nil { + t.Fatal(err) + } + + msg2R, err := requester.CreateMessage2() + if err != nil { + t.Fatal(err) + } + ser2R, err := asn1.Marshal(msg2R) + if err != nil { + t.Fatal(err) + } + toServer[i] = ser2R + } + t.Logf("created %d requesters and requests in %s", batchSize, time.Since(start)) + t.Logf("sending %d bytes to server", lenOfByteSlices(toServer)) + + // Sign requests + start = time.Now() + for i := 0; i < batchSize; i++ { + var msg2S pblind.Message2 + _, err = asn1.Unmarshal(toServer[i], &msg2S) + if err != nil { + t.Fatal(err) + } + err = signers[i].ProcessMessage2(msg2S) + if err != nil { + t.Fatal(err) + } + + msg3S, err := signers[i].CreateMessage3() + if err != nil { + t.Fatal(err) + } + ser3S, err := asn1.Marshal(msg3S) + if err != nil { + t.Fatal(err) + } + toClient[i] = ser3S + } + t.Logf("signed %d requests in %s", batchSize, time.Since(start)) + t.Logf("sending %d bytes to client", lenOfByteSlices(toClient)) + + // Verify signed requests + start = time.Now() + for i := 0; i < batchSize; i++ { + var msg3R pblind.Message3 + _, err := asn1.Unmarshal(toClient[i], &msg3R) + if err != nil { + t.Fatal(err) + } + err = requesters[i].ProcessMessage3(msg3R) + if err != nil { + t.Fatal(err) + } + signature, err := requesters[i].Signature() + if err != nil { + t.Fatal(err) + } + sig, err := asn1.Marshal(signature) + if err != nil { + t.Fatal(err) + } + toServer[i] = sig + + // check signature + if !pk.Check(signature, info, msgStr) { + t.Fatal("signature invalid") + } + } + t.Logf("finalized and verified %d signed tokens in %s", batchSize, time.Since(start)) + t.Logf("stored %d signed tokens in %d bytes", batchSize, lenOfByteSlices(toServer)) + + // Verify on server + start = time.Now() + for i := 0; i < batchSize; i++ { + var sig pblind.Signature + _, err := asn1.Unmarshal(toServer[i], &sig) + if err != nil { + t.Fatal(err) + } + + // check signature + if !pk.Check(sig, info, msgStr) { + t.Fatal("signature invalid") + } + } + t.Logf("verified %d signed tokens in %s", batchSize, time.Since(start)) + + t.Logf("process complete") + t.Logf("simulated the whole process for %d tokens in %s", batchSize, time.Since(totalStart)) +} + +func lenOfByteSlices(v [][]byte) (length int) { + for _, s := range v { + length += len(s) + } + return +} diff --git a/spn/access/token/registry.go b/spn/access/token/registry.go new file mode 100644 index 00000000..d20ec6f0 --- /dev/null +++ b/spn/access/token/registry.go @@ -0,0 +1,116 @@ +package token + +import "sync" + +// Handler represents a token handling system. +type Handler interface { + // Zone returns the zone name. + Zone() string + + // ShouldRequest returns whether the new tokens should be requested. + ShouldRequest() bool + + // Amount returns the current amount of tokens in this handler. + Amount() int + + // IsFallback returns whether this handler should only be used as a fallback. + IsFallback() bool + + // GetToken returns a token. + GetToken() (token *Token, err error) + + // Verify verifies the given token. + Verify(token *Token) error + + // Save serializes and returns the current tokens. + Save() ([]byte, error) + + // Load loads the given tokens into the handler. + Load(data []byte) error + + // Clear clears all the tokens in the handler. + Clear() +} + +var ( + registry map[string]Handler + pblindRegistry []*PBlindHandler + scrambleRegistry []*ScrambleHandler + + registryLock sync.RWMutex +) + +func init() { + initRegistry() +} + +func initRegistry() { + registry = make(map[string]Handler) + pblindRegistry = make([]*PBlindHandler, 0, 1) + scrambleRegistry = make([]*ScrambleHandler, 0, 1) +} + +// RegisterPBlindHandler registers a pblind handler with the registry. +func RegisterPBlindHandler(h *PBlindHandler) error { + registryLock.Lock() + defer registryLock.Unlock() + + if err := registerHandler(h, h.opts.Zone); err != nil { + return err + } + + pblindRegistry = append(pblindRegistry, h) + return nil +} + +// RegisterScrambleHandler registers a scramble handler with the registry. +func RegisterScrambleHandler(h *ScrambleHandler) error { + registryLock.Lock() + defer registryLock.Unlock() + + if err := registerHandler(h, h.opts.Zone); err != nil { + return err + } + + scrambleRegistry = append(scrambleRegistry, h) + return nil +} + +func registerHandler(h Handler, zone string) error { + if zone == "" { + return ErrNoZone + } + + _, ok := registry[zone] + if ok { + return ErrZoneTaken + } + + registry[zone] = h + return nil +} + +// GetHandler returns the handler of the given zone. +func GetHandler(zone string) (handler Handler, ok bool) { + registryLock.RLock() + defer registryLock.RUnlock() + + handler, ok = registry[zone] + return +} + +// ResetRegistry resets the token handler registry. +func ResetRegistry() { + registryLock.Lock() + defer registryLock.Unlock() + + initRegistry() +} + +// RegistrySize returns the amount of handler registered. +func RegistrySize() int { + registryLock.Lock() + defer registryLock.Unlock() + + return len(registry) +} diff --git a/spn/access/token/request.go b/spn/access/token/request.go new file mode 100644 index 00000000..70e9422a --- /dev/null +++ b/spn/access/token/request.go @@ -0,0 +1,244 @@ +package token + +import ( + "crypto/rand" + "errors" + "fmt" + + "github.com/mr-tron/base58" +) + +const sessionIDSize = 32 + +// RequestHandlingState is a request handling state. +type RequestHandlingState struct { + SessionID string + PBlind map[string]*PBlindSignerState +} + +// SetupRequest is a setup request. +type SetupRequest struct { + PBlind map[string]struct{} `json:"PB,omitempty"` +} + +// SetupResponse is a setup response. +type SetupResponse struct { + SessionID string `json:"ID,omitempty"` + PBlind map[string]*PBlindSetupResponse `json:"PB,omitempty"` +} + +// TokenRequest is a token request. +type TokenRequest struct { //nolint:golint // Be explicit. + SessionID string `json:"ID,omitempty"` + PBlind map[string]*PBlindTokenRequest `json:"PB,omitempty"` + Scramble map[string]*ScrambleTokenRequest `json:"S,omitempty"` +} + +// IssuedTokens are issued tokens. +type IssuedTokens struct { + PBlind map[string]*IssuedPBlindTokens `json:"PB,omitempty"` + Scramble map[string]*IssuedScrambleTokens `json:"SC,omitempty"` +} + +// CreateSetupRequest creates a combined setup request for all registered tokens, if needed. +func CreateSetupRequest() (request *SetupRequest, setupRequired bool) { + registryLock.RLock() + defer registryLock.RUnlock() + + request = &SetupRequest{ + PBlind: make(map[string]struct{}, len(pblindRegistry)), + } + + // Go through handlers and create request setups. + for _, pblindHandler := range pblindRegistry { + // Check if we need to request with this handler. + if pblindHandler.ShouldRequest() { + request.PBlind[pblindHandler.Zone()] = struct{}{} + setupRequired = true + } + } + + return +} + +// HandleSetupRequest handles a setup request for all registered tokens. +func HandleSetupRequest(request *SetupRequest) (*RequestHandlingState, *SetupResponse, error) { + registryLock.RLock() + defer registryLock.RUnlock() + + // Generate session token. + randomID := make([]byte, sessionIDSize) + n, err := rand.Read(randomID) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate session ID: %w", err) + } + if n != sessionIDSize { + return nil, nil, fmt.Errorf("failed to get full session ID: only got %d bytes", n) + } + sessionID := base58.Encode(randomID) + + // Create state and response. + state := &RequestHandlingState{ + SessionID: sessionID, + PBlind: make(map[string]*PBlindSignerState, len(pblindRegistry)), + } + setup := &SetupResponse{ + SessionID: sessionID, + PBlind: make(map[string]*PBlindSetupResponse, len(pblindRegistry)), + } + + // Go through handlers and create setups. + for _, pblindHandler := range pblindRegistry { + // Check if we have a request for this handler. + _, ok := request.PBlind[pblindHandler.Zone()] + if !ok { + continue + } + + plindState, pblindSetup, err := pblindHandler.CreateSetup() + if err != nil { + return nil, nil, fmt.Errorf("failed to create setup for %s: %w", pblindHandler.Zone(), err) + } + + state.PBlind[pblindHandler.Zone()] = plindState + setup.PBlind[pblindHandler.Zone()] = pblindSetup + } + + return state, setup, nil +} + +// CreateTokenRequest creates a token request for all registered tokens. +func CreateTokenRequest(setup *SetupResponse) (request *TokenRequest, requestRequired bool, err error) { + registryLock.RLock() + defer registryLock.RUnlock() + + // Check setup data. + if setup != nil && setup.SessionID == "" { + return nil, false, errors.New("setup data is missing a session ID") + } + + // Create token request. + request = &TokenRequest{ + PBlind: make(map[string]*PBlindTokenRequest, len(pblindRegistry)), + Scramble: make(map[string]*ScrambleTokenRequest, len(scrambleRegistry)), + } + if setup != nil { + request.SessionID = setup.SessionID + } + + // Go through handlers and create requests. + if setup != nil { + for _, pblindHandler := range pblindRegistry { + // Check if we have setup data for this handler. + pblindSetup, ok := setup.PBlind[pblindHandler.Zone()] + if !ok { + // TODO: Abort if we should have received request data. + continue + } + + // Create request. + pblindRequest, err := pblindHandler.CreateTokenRequest(pblindSetup) + if err != nil { + return nil, false, fmt.Errorf("failed to create token request for %s: %w", pblindHandler.Zone(), err) + } + + requestRequired = true + request.PBlind[pblindHandler.Zone()] = pblindRequest + } + } + for _, scrambleHandler := range scrambleRegistry { + // Check if we need to request with this handler. + if scrambleHandler.ShouldRequest() { + requestRequired = true + request.Scramble[scrambleHandler.Zone()] = scrambleHandler.CreateTokenRequest() + } + } + + return request, requestRequired, nil +} + +// IssueTokens issues tokens for all registered tokens. +func IssueTokens(state *RequestHandlingState, request *TokenRequest) (response *IssuedTokens, err error) { + registryLock.RLock() + defer registryLock.RUnlock() + + // Create token response. + response = &IssuedTokens{ + PBlind: make(map[string]*IssuedPBlindTokens, len(pblindRegistry)), + Scramble: make(map[string]*IssuedScrambleTokens, len(scrambleRegistry)), + } + + // Go through handlers and create requests. + for _, pblindHandler := range pblindRegistry { + // Check if we have all the data for issuing. + pblindState, ok := state.PBlind[pblindHandler.Zone()] + if !ok { + continue + } + pblindRequest, ok := request.PBlind[pblindHandler.Zone()] + if !ok { + continue + } + + // Issue tokens. + pblindTokens, err := pblindHandler.IssueTokens(pblindState, pblindRequest) + if err != nil { + return nil, fmt.Errorf("failed to issue tokens for %s: %w", pblindHandler.Zone(), err) + } + + response.PBlind[pblindHandler.Zone()] = pblindTokens + } + for _, scrambleHandler := range scrambleRegistry { + // Check if we have all the data for issuing. + scrambleRequest, ok := request.Scramble[scrambleHandler.Zone()] + if !ok { + continue + } + + // Issue tokens. + scrambleTokens, err := scrambleHandler.IssueTokens(scrambleRequest) + if err != nil { + return nil, fmt.Errorf("failed to issue tokens for %s: %w", scrambleHandler.Zone(), err) + } + + response.Scramble[scrambleHandler.Zone()] = scrambleTokens + } + + return response, nil +} + +// ProcessIssuedTokens processes issued tokens for all registered tokens. +func ProcessIssuedTokens(response *IssuedTokens) error { + registryLock.RLock() + defer registryLock.RUnlock() + + // Go through handlers and create requests. + for _, pblindHandler := range pblindRegistry { + // Check if we received tokens. + pblindResponse, ok := response.PBlind[pblindHandler.Zone()] + if !ok { + continue + } + + // Process issued tokens. + err := pblindHandler.ProcessIssuedTokens(pblindResponse) + if err != nil { + return fmt.Errorf("failed to process issued tokens for %s: %w", pblindHandler.Zone(), err) + } + } + for _, scrambleHandler := range scrambleRegistry { + // Check if we received tokens. + scrambleResponse, ok := response.Scramble[scrambleHandler.Zone()] + if !ok { + continue + } + + // Process issued tokens. + err := scrambleHandler.ProcessIssuedTokens(scrambleResponse) + if err != nil { + return fmt.Errorf("failed to process issued tokens for %s: %w", scrambleHandler.Zone(), err) + } + } + + return nil +} diff --git a/spn/access/token/request_test.go b/spn/access/token/request_test.go new file mode 100644 index 00000000..7040672a --- /dev/null +++ b/spn/access/token/request_test.go @@ -0,0 +1,125 @@ +package token + +import ( + "testing" + "time" + + "github.com/safing/portbase/formats/dsd" +) + +func TestFull(t *testing.T) { + t.Parallel() + + testStart := time.Now() + + // Roundtrip 1 + + start := time.Now() + setupRequest, setupRequired := CreateSetupRequest() + if !setupRequired { + t.Fatal("setup should be required") + } + setupRequestData, err := dsd.Dump(setupRequest, dsd.CBOR) + if err != nil { + t.Fatal(err) + } + setupRequest = nil // nolint:ineffassign,wastedassign // Just to be sure. + t.Logf("setupRequest: %s, %d bytes", time.Since(start), len(setupRequestData)) + + start = time.Now() + loadedSetupRequest := &SetupRequest{} + _, err = dsd.Load(setupRequestData, loadedSetupRequest) + if err != nil { + t.Fatal(err) + } + serverState, setupResponse, err := HandleSetupRequest(loadedSetupRequest) + if err != nil { + t.Fatal(err) + } + setupResponseData, err := dsd.Dump(setupResponse, dsd.CBOR) + if err != nil { + t.Fatal(err) + } + setupResponse = nil // nolint:ineffassign,wastedassign // Just to be sure. + t.Logf("setupResponse: %s, %d bytes", time.Since(start), len(setupResponseData)) + + // Roundtrip 2 + + start = time.Now() + loadedSetupResponse := &SetupResponse{} + _, err = dsd.Load(setupResponseData, loadedSetupResponse) + if err != nil { + t.Fatal(err) + } + request, requestRequired, err := CreateTokenRequest(loadedSetupResponse) + if err != nil { + t.Fatal(err) + } + if !requestRequired { + t.Fatal("request should be required") + } + requestData, err := dsd.Dump(request, dsd.CBOR) + if err != nil { + t.Fatal(err) + } + request = nil // nolint:ineffassign,wastedassign // Just to be sure. + t.Logf("request: %s, %d bytes", time.Since(start), len(requestData)) + + start = time.Now() + loadedRequest := &TokenRequest{} + _, err = dsd.Load(requestData, loadedRequest) + if err != nil { + t.Fatal(err) + } + response, err := IssueTokens(serverState, loadedRequest) + if err != nil { + t.Fatal(err) + } + responseData, err := dsd.Dump(response, dsd.CBOR) + if err != nil { + t.Fatal(err) + } + response = nil // nolint:ineffassign,wastedassign // Just to be sure. + t.Logf("response: %s, %d bytes", time.Since(start), len(responseData)) + + start = time.Now() + loadedResponse := &IssuedTokens{} + _, err = dsd.Load(responseData, loadedResponse) + if err != nil { + t.Fatal(err) + } + err = ProcessIssuedTokens(loadedResponse) + if err != nil { + t.Fatal(err) + } + t.Logf("processing: %s", time.Since(start)) + + // Token Usage + + for _, testZone := range []string{ + PBlindTestZone, + ScrambleTestZone, + } { + start = time.Now() + + token, err := GetToken(testZone) + if err != nil { + t.Fatal(err) + } + tokenData := token.Raw() + token = nil // nolint:wastedassign // Just to be sure. + + loadedToken, err := ParseRawToken(tokenData) + if err != nil { + t.Fatal(err) + } + err = VerifyToken(loadedToken) + if err != nil { + t.Fatal(err) + } + + t.Logf("using %s token: %s", testZone, time.Since(start)) + } + + t.Logf("full simulation took %s", time.Since(testStart)) +} diff --git a/spn/access/token/scramble.go b/spn/access/token/scramble.go new file mode 100644 index 00000000..df96bcc6 --- /dev/null +++ b/spn/access/token/scramble.go @@ -0,0 +1,240 @@ +package token + +import ( + "fmt" + "sync" + + "github.com/mr-tron/base58" + + "github.com/safing/jess/lhash" + "github.com/safing/portbase/formats/dsd" +) + +const ( + scrambleSecretSize = 32 +) + +// ScrambleToken is token based on hashing. +type ScrambleToken struct { + Token []byte +} + +// Pack packs the token. +func (pbt *ScrambleToken) Pack() ([]byte, error) { + return pbt.Token, nil +} + +// UnpackScrambleToken unpacks the token. +func UnpackScrambleToken(token []byte) (*ScrambleToken, error) { + return &ScrambleToken{Token: token}, nil +} + +// ScrambleHandler is a handler for the scramble tokens. +type ScrambleHandler struct { + sync.Mutex + opts *ScrambleOptions + + storageLock sync.Mutex + Storage []*ScrambleToken + + verifiersLock sync.RWMutex + verifiers map[string]*ScrambleToken +} + +// ScrambleOptions are options for the ScrambleHandler. +type ScrambleOptions struct { + Zone string + Algorithm lhash.Algorithm + InitialTokens []string + InitialVerifiers []string + Fallback bool +} + +// ScrambleTokenRequest is a token request. +type ScrambleTokenRequest struct{} + +// IssuedScrambleTokens are issued scrambled tokens. +type IssuedScrambleTokens struct { + Tokens []*ScrambleToken +} + +// NewScrambleHandler creates a new scramble handler. +func NewScrambleHandler(opts ScrambleOptions) (*ScrambleHandler, error) { + sh := &ScrambleHandler{ + opts: &opts, + verifiers: make(map[string]*ScrambleToken, len(opts.InitialTokens)+len(opts.InitialVerifiers)), + } + + // Add initial tokens. + sh.Storage = make([]*ScrambleToken, len(opts.InitialTokens)) + for i, token := range opts.InitialTokens { + // Add to storage. + tokenData, err := base58.Decode(token) + if err != nil { + return nil, fmt.Errorf("failed to decode initial token %q: %w", token, err) + } + sh.Storage[i] = &ScrambleToken{ + Token: tokenData, + } + + // Add to verifiers. + scrambledToken := lhash.Digest(sh.opts.Algorithm, tokenData).Bytes() + sh.verifiers[string(scrambledToken)] = sh.Storage[i] + } + + // Add initial verifiers. + for _, verifier := range opts.InitialVerifiers { + verifierData, err := base58.Decode(verifier) + if err != nil { + return nil, fmt.Errorf("failed to decode verifier %q: %w", verifier, err) + } + sh.verifiers[string(verifierData)] = &ScrambleToken{} + } + + return sh, nil +} + +// Zone returns the zone name. +func (sh *ScrambleHandler) Zone() string { + return sh.opts.Zone +} + +// ShouldRequest returns whether the new tokens should be requested. +func (sh *ScrambleHandler) ShouldRequest() bool { + sh.storageLock.Lock() + defer sh.storageLock.Unlock() + + return len(sh.Storage) == 0 +} + +// Amount returns the current amount of tokens in this handler. +func (sh *ScrambleHandler) Amount() int { + sh.storageLock.Lock() + defer sh.storageLock.Unlock() + + return len(sh.Storage) +} + +// IsFallback returns whether this handler should only be used as a fallback. +func (sh *ScrambleHandler) IsFallback() bool { + return sh.opts.Fallback +} + +// CreateTokenRequest creates a token request to be sent to the token server. +func (sh *ScrambleHandler) CreateTokenRequest() (request *ScrambleTokenRequest) { + return &ScrambleTokenRequest{} +} + +// IssueTokens sign the requested tokens. +func (sh *ScrambleHandler) IssueTokens(request *ScrambleTokenRequest) (response *IssuedScrambleTokens, err error) { + // Copy the storage. + tokens := make([]*ScrambleToken, len(sh.Storage)) + copy(tokens, sh.Storage) + + return &IssuedScrambleTokens{ + Tokens: tokens, + }, nil +} + +// ProcessIssuedTokens processes the issued token from the server. +func (sh *ScrambleHandler) ProcessIssuedTokens(issuedTokens *IssuedScrambleTokens) error { + sh.verifiersLock.RLock() + defer sh.verifiersLock.RUnlock() + + // Validate tokens. + for i, newToken := range issuedTokens.Tokens { + // Scramle token. + scrambledToken := lhash.Digest(sh.opts.Algorithm, newToken.Token).Bytes() + + // Check if token is valid. + _, ok := sh.verifiers[string(scrambledToken)] + if !ok { + return fmt.Errorf("invalid token on #%d", i) + } + } + + // Copy to storage. + sh.Storage = issuedTokens.Tokens + + return nil +} + +// Verify verifies the given token. +func (sh *ScrambleHandler) Verify(token *Token) error { + if token.Zone != sh.opts.Zone { + return ErrZoneMismatch + } + + // Hash the data. + scrambledToken := lhash.Digest(sh.opts.Algorithm, token.Data).Bytes() + + sh.verifiersLock.RLock() + defer sh.verifiersLock.RUnlock() + + // Check if token is valid. + _, ok := sh.verifiers[string(scrambledToken)] + if !ok { + return ErrTokenInvalid + } + + return nil +} + +// GetToken returns a token. +func (sh *ScrambleHandler) GetToken() (*Token, error) { + sh.storageLock.Lock() + defer sh.storageLock.Unlock() + + if len(sh.Storage) == 0 { + return nil, ErrEmpty + } + + return &Token{ + Zone: sh.opts.Zone, + Data: sh.Storage[0].Token, + }, nil +} + +// ScrambleStorage is a storage for scramble tokens. +type ScrambleStorage struct { + Storage []*ScrambleToken +} + +// Save serializes and returns the current tokens. +func (sh *ScrambleHandler) Save() ([]byte, error) { + sh.storageLock.Lock() + defer sh.storageLock.Unlock() + + if len(sh.Storage) == 0 { + return nil, ErrEmpty + } + + s := &ScrambleStorage{ + Storage: sh.Storage, + } + + return dsd.Dump(s, dsd.CBOR) +} + +// Load loads the given tokens into the handler. +func (sh *ScrambleHandler) Load(data []byte) error { + sh.storageLock.Lock() + defer sh.storageLock.Unlock() + + s := &ScrambleStorage{} + _, err := dsd.Load(data, s) + if err != nil { + return err + } + + sh.Storage = s.Storage + return nil +} + +// Clear clears all the tokens in the handler. +func (sh *ScrambleHandler) Clear() { + sh.storageLock.Lock() + defer sh.storageLock.Unlock() + + sh.Storage = nil +} diff --git a/spn/access/token/scramble_gen_test.go b/spn/access/token/scramble_gen_test.go new file mode 100644 index 00000000..91a7d32e --- /dev/null +++ b/spn/access/token/scramble_gen_test.go @@ -0,0 +1,48 @@ +package token + +import ( + "crypto/rand" + "fmt" + "testing" + + "github.com/mr-tron/base58" + + "github.com/safing/jess/lhash" +) + +type genAlgs struct { + alg lhash.Algorithm + name string +} + +func TestGenerateScrambleKeys(t *testing.T) { + t.Parallel() + + for _, alg := range []genAlgs{ + {alg: lhash.SHA2_256, name: "SHA2_256"}, + {alg: lhash.SHA3_256, name: "SHA3_256"}, + {alg: lhash.SHA3_512, name: "SHA3_512"}, + {alg: lhash.BLAKE2b_256, name: "BLAKE2b_256"}, + } { + token := make([]byte, scrambleSecretSize) + n, err := rand.Read(token) + if err != nil { + t.Fatal(err) + } + if n != scrambleSecretSize { + t.Fatalf("only got %d bytes", n) + } + scrambledToken := lhash.Digest(alg.alg, token).Bytes() + + fmt.Printf( + "%s secret token: %s\n", + alg.name, + base58.Encode(token), + ) + fmt.Printf( + "%s scrambled (public) token: %s\n", + alg.name, + base58.Encode(scrambledToken), + ) + } +} diff --git a/spn/access/token/scramble_test.go b/spn/access/token/scramble_test.go new file mode 100644 index 00000000..765d7007 --- /dev/null +++ b/spn/access/token/scramble_test.go @@ -0,0 +1,84 @@ +package token + +import ( + "testing" + + "github.com/safing/jess/lhash" +) + +const ScrambleTestZone = "test-scramble" + +func init() { + // Combined testing config. + + h, err := NewScrambleHandler(ScrambleOptions{ + Zone: ScrambleTestZone, + Algorithm: lhash.SHA2_256, + InitialTokens: []string{"2VqJ8BvDew1tUpytZhR7tuvq7ToPpW3tQtHvu3veE3iW"}, + }) + if err != nil { + panic(err) + } + + err = RegisterScrambleHandler(h) + if err != nil { + panic(err) + } +} + +func TestScramble(t *testing.T) { + t.Parallel() + + opts := &ScrambleOptions{ + Zone: ScrambleTestZone, + Algorithm: lhash.SHA2_256, + } + + // Issuer + opts.InitialTokens = []string{"2VqJ8BvDew1tUpytZhR7tuvq7ToPpW3tQtHvu3veE3iW"} + issuer, err := NewScrambleHandler(*opts) + if err != nil { + t.Fatal(err) + } + + // Client + opts.InitialTokens = nil + opts.InitialVerifiers = []string{"Cy9tz37Xq9NiXGDRU9yicjGU62GjXskE9KqUmuoddSxaE3"} + client, err := NewScrambleHandler(*opts) + if err != nil { + t.Fatal(err) + } + + // Verifier + verifier, err := NewScrambleHandler(*opts) + if err != nil { + t.Fatal(err) + } + + // Play through the whole use case. + + request := client.CreateTokenRequest() + if err != nil { + t.Fatal(err) + } + + issuedTokens, err := issuer.IssueTokens(request) + if err != nil { + t.Fatal(err) + } + + err = client.ProcessIssuedTokens(issuedTokens) + if err != nil { + t.Fatal(err) + } + + token, err := client.GetToken() + if err != nil { + t.Fatal(err) + } + + err = verifier.Verify(token) + if err != nil { + t.Fatal(err) + } +} diff --git a/spn/access/token/token.go b/spn/access/token/token.go new file mode 100644 index 00000000..b93ed194 --- /dev/null +++ b/spn/access/token/token.go @@ -0,0 +1,83 @@ +package token + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "github.com/mr-tron/base58" + + "github.com/safing/portbase/container" +) + +// Token represents a token, consisting of a zone (name) and some data. +type Token struct { + Zone string + Data []byte +} + +// GetToken returns a token of the given zone. +func GetToken(zone string) (*Token, error) { + handler, ok := GetHandler(zone) + if !ok { + return nil, ErrZoneUnknown + } + + return handler.GetToken() +} + +// VerifyToken verifies the given token. +func VerifyToken(token *Token) error { + handler, ok := GetHandler(token.Zone) + if !ok { + return ErrZoneUnknown + } + + return handler.Verify(token) +} + +// Raw returns the raw format of the token. +func (c *Token) Raw() []byte { + cont := container.New() + cont.Append([]byte(c.Zone)) + cont.Append([]byte(":")) + cont.Append(c.Data) + return cont.CompileData() +} + +// String returns the stringified format of the token. +func (c *Token) String() string { + return c.Zone + ":" + base58.Encode(c.Data) +} + +// ParseRawToken parses a raw token. +func ParseRawToken(code []byte) (*Token, error) { + splitted := bytes.SplitN(code, []byte(":"), 2) + if len(splitted) < 2 { + return nil, errors.New("invalid code format: zone/data separator missing") + } + + return &Token{ + Zone: string(splitted[0]), + Data: splitted[1], + }, nil +} + +// ParseToken parses a stringified token. +func ParseToken(code string) (*Token, error) { + splitted := strings.SplitN(code, ":", 2) + if len(splitted) < 2 { + return nil, errors.New("invalid code format: zone/data separator missing") + } + + data, err := base58.Decode(splitted[1]) + if err != nil { + return nil, fmt.Errorf("invalid code format: %w", err) + } + + return &Token{ + Zone: splitted[0], + Data: data, + }, nil +} diff --git a/spn/access/token/token_test.go b/spn/access/token/token_test.go new file mode 100644 index 00000000..b132265a --- /dev/null +++ b/spn/access/token/token_test.go @@ -0,0 +1,33 @@ +package token + +import ( + "testing" + + "github.com/safing/portbase/rng" +) + +func TestToken(t *testing.T) { + t.Parallel() + + randomData, err := rng.Bytes(32) + if err != nil { + t.Fatal(err) + } + + c := &Token{ + Zone: "test", + Data: randomData, + } + + s := c.String() + _, err = ParseToken(s) + if err != nil { + t.Fatal(err) + } + + r := c.Raw() + _, err = ParseRawToken(r) + if err != nil { + t.Fatal(err) + } +} diff --git a/spn/access/zones.go b/spn/access/zones.go new file mode 100644 index 00000000..1f9c954b --- /dev/null +++ b/spn/access/zones.go @@ -0,0 +1,257 @@ +package access + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/tevino/abool" + + "github.com/safing/jess/lhash" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/access/token" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/terminal" +) + +var ( + // ExpandAndConnectZones are the zones that grant access to the expand and + // connect operations. + ExpandAndConnectZones = []string{"pblind1", "alpha2", "fallback1"} + + zonePermissions = map[string]terminal.Permission{ + "pblind1": terminal.AddPermissions(terminal.MayExpand, terminal.MayConnect), + "alpha2": terminal.AddPermissions(terminal.MayExpand, terminal.MayConnect), + "fallback1": terminal.AddPermissions(terminal.MayExpand, terminal.MayConnect), + } + persistentZones = ExpandAndConnectZones + + enableTestMode = abool.New() +) + +// EnableTestMode enables the test mode, leading the access module to only +// register a test zone. +// This should not be used to test the access module itself. +func EnableTestMode() { + enableTestMode.Set() +} + +// InitializeZones initialized the permission zones. +// It initializes the test zones, if EnableTestMode was called before. +// Must only be called once. +func InitializeZones() error { + // Check if we are testing. + if enableTestMode.IsSet() { + return initializeTestZone() + } + + // Special client zone config. + var requestSignalHandler func(token.Handler) + if conf.Client() { + requestSignalHandler = shouldRequestTokensHandler + } + + // Register pblind1 as the first primary zone. + ph, err := token.NewPBlindHandler(token.PBlindOptions{ + Zone: "pblind1", + CurveName: "P-256", + PublicKey: "eXoJXzXbM66UEsM2eVi9HwyBPLMfVnNrC7gNrsfMUJDs", + UseSerials: true, + BatchSize: 1000, + RandomizeOrder: true, + SignalShouldRequest: requestSignalHandler, + }) + if err != nil { + return fmt.Errorf("failed to create pblind1 token handler: %w", err) + } + err = token.RegisterPBlindHandler(ph) + if err != nil { + return fmt.Errorf("failed to register pblind1 token handler: %w", err) + } + + // Register fallback1 zone as fallback when the issuer is not available. + sh, err := token.NewScrambleHandler(token.ScrambleOptions{ + Zone: "fallback1", + Algorithm: lhash.BLAKE2b_256, + InitialVerifiers: []string{"ZwkQoaAttVBMURzeLzNXokFBMAMUUwECfM1iHojcVKBmjk"}, + Fallback: true, + }) + if err != nil { + return fmt.Errorf("failed to create fallback1 token handler: %w", err) + } + err = token.RegisterScrambleHandler(sh) + if err != nil { + return fmt.Errorf("failed to register fallback1 token handler: %w", err) + } + + // Register alpha2 zone for transition phase. + sh, err = token.NewScrambleHandler(token.ScrambleOptions{ + Zone: "alpha2", + Algorithm: lhash.BLAKE2b_256, + InitialVerifiers: []string{"ZwojEvXZmAv7SZdNe7m94Xzu7F9J8vULqKf7QYtoTpN2tH"}, + }) + if err != nil { + return fmt.Errorf("failed to create alpha2 token handler: %w", err) + } + err = token.RegisterScrambleHandler(sh) + if err != nil { + return fmt.Errorf("failed to register alpha2 token handler: %w", err) + } + + return nil +} + +func initializeTestZone() error { + // Safeguard checks if we should really enable the test zone. + if !strings.HasSuffix(os.Args[0], ".test") { + return errors.New("tried to enable test mode, but no test binary was detected") + } + if token.RegistrySize() > 0 { + return fmt.Errorf("tried to enable test zone, but %d handlers are already registered", token.RegistrySize()) + } + + // Reset zones. + token.ResetRegistry() + + // Set eligible zones. + ExpandAndConnectZones = []string{"unittest"} + zonePermissions = map[string]terminal.Permission{ + "unittest": terminal.AddPermissions(terminal.MayExpand, terminal.MayConnect), + } + + // Register unittest zone as for testing. + sh, err := token.NewScrambleHandler(token.ScrambleOptions{ + Zone: "unittest", + Algorithm: lhash.BLAKE2b_256, + InitialTokens: []string{"6jFqLA93uSLL52utGKrvctG3ZfopSQ8WFqjsRK1c2Svt"}, + InitialVerifiers: []string{"ZwoEoL59sr81s7WnF2vydGzjeejE3u8CqVafig1NTQzUr7"}, + }) + if err != nil { + return fmt.Errorf("failed to create unittest token handler: %w", err) + } + err = token.RegisterScrambleHandler(sh) + if err != nil { + return fmt.Errorf("failed to register unittest token handler: %w", err) + } + + return nil +} + +func shouldRequestTokensHandler(_ token.Handler) { + // accountUpdateTask is always set in client mode and when the module is online. + // Check if it's set in case this gets executed in other circumstances. + if accountUpdateTask == nil { + log.Warningf("spn/access: trying to trigger account update, but the task is not available") + return + } + + accountUpdateTask.StartASAP() +} + +// GetTokenAmount returns the amount of tokens for the given zones. +func GetTokenAmount(zones []string) (regular, fallback int) { +handlerLoop: + for _, zone := range zones { + // Get handler and check if it should be used. + handler, ok := token.GetHandler(zone) + if !ok { + log.Warningf("spn/access: use of non-registered zone %q", zone) + continue handlerLoop + } + + if handler.IsFallback() { + fallback += handler.Amount() + } else { + regular += handler.Amount() + } + } + + return +} + +// ShouldRequest returns whether tokens should be requested for the given zones. +func ShouldRequest(zones []string) (shouldRequest bool) { +handlerLoop: + for _, zone := range zones { + // Get handler and check if it should be used. + handler, ok := token.GetHandler(zone) + if !ok { + log.Warningf("spn/access: use of non-registered zone %q", zone) + continue handlerLoop + } + + // Go through all handlers every time as this will be the case anyway most + // of the time and will help us better catch zone misconfiguration. + if handler.ShouldRequest() { + shouldRequest = true + } + } + + return shouldRequest +} + +// GetToken returns a token of one of the given zones. +func GetToken(zones []string) (t *token.Token, err error) { +handlerSelection: + for _, zone := range zones { + // Get handler and check if it should be used. + handler, ok := token.GetHandler(zone) + switch { + case !ok: + log.Warningf("spn/access: use of non-registered zone %q", zone) + continue handlerSelection + case handler.IsFallback() && !TokenIssuerIsFailing(): + // Skip fallback zone if everything works. + continue handlerSelection + } + + // Get token from handler. + t, err = token.GetToken(zone) + if err == nil { + return t, nil + } + } + + // Return existing error, if exists. + if err != nil { + return nil, err + } + return nil, token.ErrEmpty +} + +// VerifyRawToken verifies a raw token. +func VerifyRawToken(data []byte) (granted terminal.Permission, err error) { + t, err := token.ParseRawToken(data) + if err != nil { + return 0, fmt.Errorf("failed to parse token: %w", err) + } + + return VerifyToken(t) +} + +// VerifyToken verifies a token. +func VerifyToken(t *token.Token) (granted terminal.Permission, err error) { + handler, ok := token.GetHandler(t.Zone) + if !ok { + return terminal.NoPermission, token.ErrZoneUnknown + } + + // Check if the token is a fallback token. + if handler.IsFallback() && !healthCheck() { + return terminal.NoPermission, ErrFallbackNotAvailable + } + + // Verify token. + err = handler.Verify(t) + if err != nil { + return 0, fmt.Errorf("failed to verify token: %w", err) + } + + // Return permission of zone. + granted, ok = zonePermissions[t.Zone] + if !ok { + return terminal.NoPermission, nil + } + return granted, nil +} diff --git a/spn/cabin/config-public.go b/spn/cabin/config-public.go new file mode 100644 index 00000000..4ae733ae --- /dev/null +++ b/spn/cabin/config-public.go @@ -0,0 +1,392 @@ +package cabin + +import ( + "fmt" + "net" + "os" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/hub" +) + +// Configuration Keys. +var ( + // Name of the node. + publicCfgOptionNameKey = "spn/publicHub/name" + publicCfgOptionName config.StringOption + publicCfgOptionNameDefault = "" + publicCfgOptionNameOrder = 512 + + // Person or organisation, who is in control of the node (should be same for all nodes of this person or organisation). + publicCfgOptionGroupKey = "spn/publicHub/group" + publicCfgOptionGroup config.StringOption + publicCfgOptionGroupDefault = "" + publicCfgOptionGroupOrder = 513 + + // Contact possibility (recommended, but optional). + publicCfgOptionContactAddressKey = "spn/publicHub/contactAddress" + publicCfgOptionContactAddress config.StringOption + publicCfgOptionContactAddressDefault = "" + publicCfgOptionContactAddressOrder = 514 + + // Type of service of the contact address, if not email. + publicCfgOptionContactServiceKey = "spn/publicHub/contactService" + publicCfgOptionContactService config.StringOption + publicCfgOptionContactServiceDefault = "" + publicCfgOptionContactServiceOrder = 515 + + // Hosters - supply chain (reseller, hosting provider, datacenter operator, ...). + publicCfgOptionHostersKey = "spn/publicHub/hosters" + publicCfgOptionHosters config.StringArrayOption + publicCfgOptionHostersDefault = []string{} + publicCfgOptionHostersOrder = 516 + + // Datacenter + // Format: CC-COMPANY-INTERNALCODE + // Eg: DE-Hetzner-FSN1-DC5 + //. + publicCfgOptionDatacenterKey = "spn/publicHub/datacenter" + publicCfgOptionDatacenter config.StringOption + publicCfgOptionDatacenterDefault = "" + publicCfgOptionDatacenterOrder = 517 + + // Network Location and Access. + + // IPv4 must be global and accessible. + publicCfgOptionIPv4Key = "spn/publicHub/ip4" + publicCfgOptionIPv4 config.StringOption + publicCfgOptionIPv4Default = "" + publicCfgOptionIPv4Order = 518 + + // IPv6 must be global and accessible. + publicCfgOptionIPv6Key = "spn/publicHub/ip6" + publicCfgOptionIPv6 config.StringOption + publicCfgOptionIPv6Default = "" + publicCfgOptionIPv6Order = 519 + + // Transports. + publicCfgOptionTransportsKey = "spn/publicHub/transports" + publicCfgOptionTransports config.StringArrayOption + publicCfgOptionTransportsDefault = []string{ + "tcp:17", + } + publicCfgOptionTransportsOrder = 520 + + // Entry Policy. + publicCfgOptionEntryKey = "spn/publicHub/entry" + publicCfgOptionEntry config.StringArrayOption + publicCfgOptionEntryDefault = []string{} + publicCfgOptionEntryOrder = 521 + + // Exit Policy. + publicCfgOptionExitKey = "spn/publicHub/exit" + publicCfgOptionExit config.StringArrayOption + publicCfgOptionExitDefault = []string{"- * TCP/25"} + publicCfgOptionExitOrder = 522 + + // Allow Unencrypted. + publicCfgOptionAllowUnencryptedKey = "spn/publicHub/allowUnencrypted" + publicCfgOptionAllowUnencrypted config.BoolOption + publicCfgOptionAllowUnencryptedDefault = false + publicCfgOptionAllowUnencryptedOrder = 523 +) + +func prepPublicHubConfig() error { + err := config.Register(&config.Option{ + Name: "Name", + Key: publicCfgOptionNameKey, + Description: "Human readable name of the Hub.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionNameDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionNameOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionName = config.GetAsString(publicCfgOptionNameKey, publicCfgOptionNameDefault) + + err = config.Register(&config.Option{ + Name: "Group", + Key: publicCfgOptionGroupKey, + Description: "Name of the hub group this Hub belongs to.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionGroupDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionGroupOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionGroup = config.GetAsString(publicCfgOptionGroupKey, publicCfgOptionGroupDefault) + + err = config.Register(&config.Option{ + Name: "Contact Address", + Key: publicCfgOptionContactAddressKey, + Description: "Contact address where the Hub operator can be reached.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionContactAddressDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionContactAddressOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionContactAddress = config.GetAsString(publicCfgOptionContactAddressKey, publicCfgOptionContactAddressDefault) + + err = config.Register(&config.Option{ + Name: "Contact Service", + Key: publicCfgOptionContactServiceKey, + Description: "Name of the service the contact address corresponds to, if not email.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionContactServiceDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionContactServiceOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionContactService = config.GetAsString(publicCfgOptionContactServiceKey, publicCfgOptionContactServiceDefault) + + err = config.Register(&config.Option{ + Name: "Hosters", + Key: publicCfgOptionHostersKey, + Description: "List of all involved entities and organisations that are involved in hosting this Hub.", + OptType: config.OptTypeStringArray, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionHostersDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionHostersOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionHosters = config.GetAsStringArray(publicCfgOptionHostersKey, publicCfgOptionHostersDefault) + + err = config.Register(&config.Option{ + Name: "Datacenter", + Key: publicCfgOptionDatacenterKey, + Description: "Identifier of the datacenter this Hub is hosted in.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionDatacenterDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionDatacenterOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionDatacenter = config.GetAsString(publicCfgOptionDatacenterKey, publicCfgOptionDatacenterDefault) + + err = config.Register(&config.Option{ + Name: "IPv4", + Key: publicCfgOptionIPv4Key, + Description: "IPv4 address of this Hub. Must be globally reachable.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionIPv4Default, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionIPv4Order, + }, + }) + if err != nil { + return err + } + publicCfgOptionIPv4 = config.GetAsString(publicCfgOptionIPv4Key, publicCfgOptionIPv4Default) + + err = config.Register(&config.Option{ + Name: "IPv6", + Key: publicCfgOptionIPv6Key, + Description: "IPv6 address of this Hub. Must be globally reachable.", + OptType: config.OptTypeString, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionIPv6Default, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionIPv6Order, + }, + }) + if err != nil { + return err + } + publicCfgOptionIPv6 = config.GetAsString(publicCfgOptionIPv6Key, publicCfgOptionIPv6Default) + + err = config.Register(&config.Option{ + Name: "Transports", + Key: publicCfgOptionTransportsKey, + Description: "List of transports this Hub supports.", + OptType: config.OptTypeStringArray, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionTransportsDefault, + ValidationFunc: func(value any) error { + if transports, ok := value.([]string); ok { + for i, transport := range transports { + if _, err := hub.ParseTransport(transport); err != nil { + return fmt.Errorf("failed to parse transport #%d: %w", i, err) + } + } + } else { + return fmt.Errorf("not a []string, but %T", value) + } + return nil + }, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionTransportsOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionTransports = config.GetAsStringArray(publicCfgOptionTransportsKey, publicCfgOptionTransportsDefault) + + err = config.Register(&config.Option{ + Name: "Entry", + Key: publicCfgOptionEntryKey, + Description: "Define an entry policy. The format is the same for the endpoint lists. Default is permit.", + OptType: config.OptTypeStringArray, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionEntryDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionEntryOrder, + config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, + }, + }) + if err != nil { + return err + } + publicCfgOptionEntry = config.GetAsStringArray(publicCfgOptionEntryKey, publicCfgOptionEntryDefault) + + err = config.Register(&config.Option{ + Name: "Exit", + Key: publicCfgOptionExitKey, + Description: "Define an exit policy. The format is the same for the endpoint lists. Default is permit.", + OptType: config.OptTypeStringArray, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionExitDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionExitOrder, + config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, + }, + }) + if err != nil { + return err + } + publicCfgOptionExit = config.GetAsStringArray(publicCfgOptionExitKey, publicCfgOptionExitDefault) + + err = config.Register(&config.Option{ + Name: "Allow Unencrypted Connections", + Key: publicCfgOptionAllowUnencryptedKey, + Description: "Advertise that this Hub is available for handling unencrypted connections, as detected by clients.", + OptType: config.OptTypeBool, + ExpertiseLevel: config.ExpertiseLevelExpert, + RequiresRestart: true, + DefaultValue: publicCfgOptionAllowUnencryptedDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: publicCfgOptionAllowUnencryptedOrder, + }, + }) + if err != nil { + return err + } + publicCfgOptionAllowUnencrypted = config.GetAsBool(publicCfgOptionAllowUnencryptedKey, publicCfgOptionAllowUnencryptedDefault) + + // update defaults from system + setDynamicPublicDefaults() + + return nil +} + +func getPublicHubInfo() *hub.Announcement { + // get configuration + info := &hub.Announcement{ + Name: publicCfgOptionName(), + Group: publicCfgOptionGroup(), + ContactAddress: publicCfgOptionContactAddress(), + ContactService: publicCfgOptionContactService(), + Hosters: publicCfgOptionHosters(), + Datacenter: publicCfgOptionDatacenter(), + Transports: publicCfgOptionTransports(), + Entry: publicCfgOptionEntry(), + Exit: publicCfgOptionExit(), + Flags: []string{}, + } + + if publicCfgOptionAllowUnencrypted() { + info.Flags = append(info.Flags, hub.FlagAllowUnencrypted) + } + + ip4 := publicCfgOptionIPv4() + if ip4 != "" { + ip := net.ParseIP(ip4) + if ip == nil { + log.Warningf("spn/cabin: invalid %s config: %s", publicCfgOptionIPv4Key, ip4) + } else { + info.IPv4 = ip + } + } + + ip6 := publicCfgOptionIPv6() + if ip6 != "" { + ip := net.ParseIP(ip6) + if ip == nil { + log.Warningf("spn/cabin: invalid %s config: %s", publicCfgOptionIPv6Key, ip6) + } else { + info.IPv6 = ip + } + } + + return info +} + +func setDynamicPublicDefaults() { + // name + hostname, err := os.Hostname() + if err == nil { + err := config.SetDefaultConfigOption(publicCfgOptionNameKey, hostname) + if err != nil { + log.Warningf("spn/cabin: failed to set %s default to %s", publicCfgOptionNameKey, hostname) + } + } + + // IPs + v4IPs, v6IPs, err := netenv.GetAssignedGlobalAddresses() + if err != nil { + log.Warningf("spn/cabin: failed to get assigned addresses: %s", err) + return + } + if len(v4IPs) == 1 { + err = config.SetDefaultConfigOption(publicCfgOptionIPv4Key, v4IPs[0].String()) + if err != nil { + log.Warningf("spn/cabin: failed to set %s default to %s", publicCfgOptionIPv4Key, v4IPs[0].String()) + } + } + if len(v6IPs) == 1 { + err = config.SetDefaultConfigOption(publicCfgOptionIPv6Key, v6IPs[0].String()) + if err != nil { + log.Warningf("spn/cabin: failed to set %s default to %s", publicCfgOptionIPv6Key, v6IPs[0].String()) + } + } +} diff --git a/spn/cabin/database.go b/spn/cabin/database.go new file mode 100644 index 00000000..41097530 --- /dev/null +++ b/spn/cabin/database.go @@ -0,0 +1,98 @@ +package cabin + +import ( + "errors" + "fmt" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/record" + "github.com/safing/portmaster/spn/hub" +) + +var db = database.NewInterface(nil) + +// LoadIdentity loads an identify with the given key. +func LoadIdentity(key string) (id *Identity, changed bool, err error) { + r, err := db.Get(key) + if err != nil { + return nil, false, err + } + id, err = EnsureIdentity(r) + if err != nil { + return nil, false, fmt.Errorf("failed to parse identity: %w", err) + } + + // Check if required fields are present. + switch { + case id.Hub == nil: + return nil, false, errors.New("missing id.Hub") + case id.Signet == nil: + return nil, false, errors.New("missing id.Signet") + case id.Hub.Info == nil: + return nil, false, errors.New("missing hub.Info") + case id.Hub.Status == nil: + return nil, false, errors.New("missing hub.Status") + case id.ID != id.Hub.ID: + return nil, false, errors.New("hub.ID mismatch") + case id.ID != id.Hub.Info.ID: + return nil, false, errors.New("hub.Info.ID mismatch") + case id.Map == "": + return nil, false, errors.New("invalid id.Map") + case id.Hub.Map == "": + return nil, false, errors.New("invalid hub.Map") + case id.Hub.FirstSeen.IsZero(): + return nil, false, errors.New("missing hub.FirstSeen") + case id.Hub.Info.Timestamp == 0: + return nil, false, errors.New("missing hub.Info.Timestamp") + case id.Hub.Status.Timestamp == 0: + return nil, false, errors.New("missing hub.Status.Timestamp") + } + + // Run a initial maintenance routine. + infoChanged, err := id.MaintainAnnouncement(nil, true) + if err != nil { + return nil, false, fmt.Errorf("failed to initialize announcement: %w", err) + } + statusChanged, err := id.MaintainStatus(nil, nil, nil, true) + if err != nil { + return nil, false, fmt.Errorf("failed to initialize status: %w", err) + } + + // Ensure the Measurements reset the values. + measurements := id.Hub.GetMeasurements() + measurements.SetLatency(0) + measurements.SetCapacity(0) + measurements.SetCalculatedCost(hub.MaxCalculatedCost) + + return id, infoChanged || statusChanged, nil +} + +// EnsureIdentity makes sure a database record is an Identity. +func EnsureIdentity(r record.Record) (*Identity, error) { + // unwrap + if r.IsWrapped() { + // only allocate a new struct, if we need it + id := &Identity{} + err := record.Unwrap(r, id) + if err != nil { + return nil, err + } + return id, nil + } + + // or adjust type + id, ok := r.(*Identity) + if !ok { + return nil, fmt.Errorf("record not of type *Identity, but %T", r) + } + return id, nil +} + +// Save saves the Identity to the database. +func (id *Identity) Save() error { + if !id.KeyIsSet() { + return errors.New("no key set") + } + + return db.Put(id) +} diff --git a/spn/cabin/identity.go b/spn/cabin/identity.go new file mode 100644 index 00000000..0be583cf --- /dev/null +++ b/spn/cabin/identity.go @@ -0,0 +1,311 @@ +package cabin + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/safing/jess" + "github.com/safing/jess/tools" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/info" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" +) + +const ( + // DefaultIDKeyScheme is the default jess tool for creating ID keys. + DefaultIDKeyScheme = "Ed25519" + + // DefaultIDKeySecurityLevel is the default security level for creating ID keys. + DefaultIDKeySecurityLevel = 256 // Ed25519 security level is fixed, setting is ignored. +) + +// Identity holds the identity of a Hub. +type Identity struct { + record.Base + + ID string + Map string + Hub *hub.Hub + Signet *jess.Signet + + ExchKeys map[string]*ExchKey + + infoExportCache []byte + statusExportCache []byte +} + +// Lock locks the Identity through the Hub lock. +func (id *Identity) Lock() { + id.Hub.Lock() +} + +// Unlock unlocks the Identity through the Hub lock. +func (id *Identity) Unlock() { + id.Hub.Unlock() +} + +// ExchKey holds the private information of a HubKey. +type ExchKey struct { + Created time.Time + Expires time.Time + key *jess.Signet + tool *tools.Tool +} + +// CreateIdentity creates a new identity. +func CreateIdentity(ctx context.Context, mapName string) (*Identity, error) { + id := &Identity{ + Map: mapName, + ExchKeys: make(map[string]*ExchKey), + } + + // create signet + signet, recipient, err := hub.CreateHubSignet(DefaultIDKeyScheme, DefaultIDKeySecurityLevel) + if err != nil { + return nil, err + } + id.Signet = signet + id.ID = signet.ID + id.Hub = &hub.Hub{ + ID: id.ID, + Map: mapName, + PublicKey: recipient, + } + + // initial maintenance routine + _, err = id.MaintainAnnouncement(nil, true) + if err != nil { + return nil, fmt.Errorf("failed to initialize announcement: %w", err) + } + _, err = id.MaintainStatus([]*hub.Lane{}, new(int), nil, true) + if err != nil { + return nil, fmt.Errorf("failed to initialize status: %w", err) + } + + return id, nil +} + +// MaintainAnnouncement maintains the Hub's Announcenemt and returns whether +// there was a change that should be communicated to other Hubs. +// If newInfo is nil, it will be derived from configuration. +func (id *Identity) MaintainAnnouncement(newInfo *hub.Announcement, selfcheck bool) (changed bool, err error) { + id.Lock() + defer id.Unlock() + + // Populate new info with data. + if newInfo == nil { + newInfo = getPublicHubInfo() + } + newInfo.ID = id.Hub.ID + if id.Hub.Info != nil { + newInfo.Timestamp = id.Hub.Info.Timestamp + } + if !newInfo.Equal(id.Hub.Info) { + changed = true + } + + if changed { + // Update timestamp. + newInfo.Timestamp = time.Now().Unix() + } + + if changed || selfcheck { + // Export new data. + newInfoData, err := newInfo.Export(id.signingEnvelope()) + if err != nil { + return false, fmt.Errorf("failed to export: %w", err) + } + + // Apply the status as all other Hubs would in order to check if it's valid. + _, _, _, err = hub.ApplyAnnouncement(id.Hub, newInfoData, conf.MainMapName, conf.MainMapScope, true) + if err != nil { + return false, fmt.Errorf("failed to apply new announcement: %w", err) + } + id.infoExportCache = newInfoData + + // Save message to hub message storage. + err = hub.SaveHubMsg(id.ID, conf.MainMapName, hub.MsgTypeAnnouncement, newInfoData) + if err != nil { + log.Warningf("spn/cabin: failed to save own new/updated announcement of %s: %s", id.ID, err) + } + } + + return changed, nil +} + +// MaintainStatus maintains the Hub's Status and returns whether there was a change that should be communicated to other Hubs. +func (id *Identity) MaintainStatus(lanes []*hub.Lane, load *int, flags []string, selfcheck bool) (changed bool, err error) { + id.Lock() + defer id.Unlock() + + // Create a new status or make a copy of the status for editing. + var newStatus *hub.Status + if id.Hub.Status != nil { + newStatus = id.Hub.Status.Copy() + } else { + newStatus = &hub.Status{} + } + + // Update software version. + if newStatus.Version != info.Version() { + newStatus.Version = info.Version() + changed = true + } + + // Update keys. + keysChanged, err := id.MaintainExchKeys(newStatus, time.Now()) + if err != nil { + return false, fmt.Errorf("failed to maintain keys: %w", err) + } + if keysChanged { + changed = true + } + + // Update lanes. + if lanes != nil && !hub.LanesEqual(newStatus.Lanes, lanes) { + newStatus.Lanes = lanes + changed = true + } + + // Update load. + if load != nil && newStatus.Load != *load { + newStatus.Load = *load + changed = true + } + + // Update flags. + if !hub.FlagsEqual(newStatus.Flags, flags) { + newStatus.Flags = flags + changed = true + } + + // Update timestamp if something changed. + if changed { + newStatus.Timestamp = time.Now().Unix() + } + + if changed || selfcheck { + // Export new data. + newStatusData, err := newStatus.Export(id.signingEnvelope()) + if err != nil { + return false, fmt.Errorf("failed to export: %w", err) + } + + // Apply the status as all other Hubs would in order to check if it's valid. + _, _, _, err = hub.ApplyStatus(id.Hub, newStatusData, conf.MainMapName, conf.MainMapScope, true) + if err != nil { + return false, fmt.Errorf("failed to apply new status: %w", err) + } + id.statusExportCache = newStatusData + + // Save message to hub message storage. + err = hub.SaveHubMsg(id.ID, conf.MainMapName, hub.MsgTypeStatus, newStatusData) + if err != nil { + log.Warningf("spn/cabin: failed to save own new/updated status: %s", err) + } + } + + return changed, nil +} + +// MakeOfflineStatus creates and signs an offline status message. +func (id *Identity) MakeOfflineStatus() (offlineStatusExport []byte, err error) { + // Make offline status. + newStatus := &hub.Status{ + Timestamp: time.Now().Unix(), + Version: info.Version(), + Flags: []string{hub.FlagOffline}, + } + + // Export new data. + newStatusData, err := newStatus.Export(id.signingEnvelope()) + if err != nil { + return nil, fmt.Errorf("failed to export: %w", err) + } + + return newStatusData, nil +} + +func (id *Identity) signingEnvelope() *jess.Envelope { + env := jess.NewUnconfiguredEnvelope() + env.SuiteID = jess.SuiteSignV1 + env.Senders = []*jess.Signet{id.Signet} + + return env +} + +// ExportAnnouncement serializes and signs the Announcement. +func (id *Identity) ExportAnnouncement() ([]byte, error) { + id.Lock() + defer id.Unlock() + + if id.infoExportCache == nil { + return nil, errors.New("announcement not exported") + } + + return id.infoExportCache, nil +} + +// ExportStatus serializes and signs the Status. +func (id *Identity) ExportStatus() ([]byte, error) { + id.Lock() + defer id.Unlock() + + if id.statusExportCache == nil { + return nil, errors.New("status not exported") + } + + return id.statusExportCache, nil +} + +// SignHubMsg signs a data blob with the identity's private key. +func (id *Identity) SignHubMsg(data []byte) ([]byte, error) { + return hub.SignHubMsg(data, id.signingEnvelope(), false) +} + +// GetSignet returns the private exchange key with the given ID. +func (id *Identity) GetSignet(keyID string, recipient bool) (*jess.Signet, error) { + if recipient { + return nil, errors.New("cabin.Identity only serves private keys") + } + + id.Lock() + defer id.Unlock() + + key, ok := id.ExchKeys[keyID] + if !ok { + return nil, errors.New("the requested key does not exist") + } + if time.Now().After(key.Expires) || key.key == nil { + return nil, errors.New("the requested key has expired") + } + + return key.key, nil +} + +func (ek *ExchKey) toHubKey() (*hub.Key, error) { + if ek.key == nil { + return nil, errors.New("no key") + } + + // export public key + rcpt, err := ek.key.AsRecipient() + if err != nil { + return nil, err + } + err = rcpt.StoreKey() + if err != nil { + return nil, err + } + + // repackage + return &hub.Key{ + Scheme: rcpt.Scheme, + Key: rcpt.Key, + Expires: ek.Expires.Unix(), + }, nil +} diff --git a/spn/cabin/identity_test.go b/spn/cabin/identity_test.go new file mode 100644 index 00000000..6ad0530d --- /dev/null +++ b/spn/cabin/identity_test.go @@ -0,0 +1,129 @@ +package cabin + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" +) + +func TestIdentity(t *testing.T) { + t.Parallel() + + // Register config options for public hub. + if err := prepPublicHubConfig(); err != nil { + t.Fatal(err) + } + + // Create new identity. + identityTestKey := "core:spn/public/identity" + id, err := CreateIdentity(module.Ctx, conf.MainMapName) + if err != nil { + t.Fatal(err) + } + id.SetKey(identityTestKey) + + // Check values + // Identity + assert.NotEmpty(t, id.ID, "id.ID must be set") + assert.NotEmpty(t, id.Map, "id.Map must be set") + assert.NotNil(t, id.Signet, "id.Signet must be set") + assert.NotNil(t, id.infoExportCache, "id.infoExportCache must be set") + assert.NotNil(t, id.statusExportCache, "id.statusExportCache must be set") + // Hub + assert.NotEmpty(t, id.Hub.ID, "hub.ID must be set") + assert.NotEmpty(t, id.Hub.Map, "hub.Map must be set") + assert.NotZero(t, id.Hub.FirstSeen, "hub.FirstSeen must be set") + // Info + assert.NotEmpty(t, id.Hub.Info.ID, "info.ID must be set") + assert.NotEqual(t, 0, id.Hub.Info.Timestamp, "info.Timestamp must be set") + assert.NotEqual(t, "", id.Hub.Info.Name, "info.Name must be set (to hostname)") + // Status + assert.NotEqual(t, 0, id.Hub.Status.Timestamp, "status.Timestamp must be set") + assert.NotEmpty(t, id.Hub.Status.Keys, "status.Keys must be set") + + fmt.Printf("id: %+v\n", id) + fmt.Printf("id.hub: %+v\n", id.Hub) + fmt.Printf("id.Hub.Info: %+v\n", id.Hub.Info) + fmt.Printf("id.Hub.Status: %+v\n", id.Hub.Status) + + // Maintenance is run in creation, so nothing should change now. + changed, err := id.MaintainAnnouncement(nil, false) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("unexpected change of announcement") + } + changed, err = id.MaintainStatus(nil, nil, nil, false) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("unexpected change of status") + } + + // Change lanes. + lanes := []*hub.Lane{ + { + ID: "A", + Capacity: 1, + Latency: 2, + }, + { + ID: "B", + Capacity: 3, + Latency: 4, + }, + { + ID: "C", + Capacity: 5, + Latency: 6, + }, + } + changed, err = id.MaintainStatus(lanes, new(int), nil, false) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("status should have changed") + } + + // Change nothing. + changed, err = id.MaintainStatus(lanes, new(int), nil, false) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("unexpected change of status") + } + + // Exporting + _, err = id.ExportAnnouncement() + if err != nil { + t.Fatal(err) + } + _, err = id.ExportStatus() + if err != nil { + t.Fatal(err) + } + + // Save to and load from database. + err = id.Save() + if err != nil { + t.Fatal(err) + } + id2, changed, err := LoadIdentity(identityTestKey) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("unexpected change") + } + + // Check if they match + assert.Equal(t, id, id2, "identities should be equal") +} diff --git a/spn/cabin/keys.go b/spn/cabin/keys.go new file mode 100644 index 00000000..67d203a4 --- /dev/null +++ b/spn/cabin/keys.go @@ -0,0 +1,179 @@ +package cabin + +import ( + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/safing/jess" + "github.com/safing/jess/tools" + "github.com/safing/portbase/log" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/hub" +) + +type providedExchKeyScheme struct { + id string + securityLevel int //nolint:structcheck // TODO + tool *tools.Tool +} + +var ( + // validFor defines how long keys are valid for use by clients. + validFor = 48 * time.Hour // 2 days + // renewBeforeExpiry defines the duration how long before expiry keys should be renewed. + renewBeforeExpiry = 24 * time.Hour // 1 day + + // burnAfter defines how long after expiry keys are burnt/deleted. + burnAfter = 12 * time.Hour // 1/2 day + // reuseAfter defines how long IDs should be blocked after expiry (and not be reused for new keys). + reuseAfter = 2 * 7 * 24 * time.Hour // 2 weeks + + // provideExchKeySchemes defines the jess tools for creating exchange keys. + provideExchKeySchemes = []*providedExchKeyScheme{ + { + id: "ECDH-X25519", + securityLevel: 128, // informative only, security level of ECDH-X25519 is fixed + }, + // TODO: test with rsa keys + } +) + +func initProvidedExchKeySchemes() error { + for _, eks := range provideExchKeySchemes { + tool, err := tools.Get(eks.id) + if err != nil { + return err + } + eks.tool = tool + } + return nil +} + +// MaintainExchKeys maintains the exchange keys, creating new ones and +// deprecating and deleting old ones. +func (id *Identity) MaintainExchKeys(newStatus *hub.Status, now time.Time) (changed bool, err error) { + // create Keys map + if id.ExchKeys == nil { + id.ExchKeys = make(map[string]*ExchKey) + } + + // lifecycle management + for keyID, exchKey := range id.ExchKeys { + if exchKey.key != nil && now.After(exchKey.Expires.Add(burnAfter)) { + // delete key + err := exchKey.tool.StaticLogic.BurnKey(exchKey.key) + if err != nil { + log.Warningf( + "spn/cabin: failed to burn key %s (%s) of %s: %s", + keyID, + exchKey.tool.Info.Name, + id.Hub.ID, + err, + ) + } + // remove reference + exchKey.key = nil + } + if now.After(exchKey.Expires.Add(reuseAfter)) { + // remove key + delete(id.ExchKeys, keyID) + } + } + + // find or create current keys + for _, eks := range provideExchKeySchemes { + found := false + for _, exchKey := range id.ExchKeys { + if exchKey.key != nil && + exchKey.key.Scheme == eks.id && + now.Before(exchKey.Expires.Add(-renewBeforeExpiry)) { + found = true + break + } + } + + if !found { + err := id.createExchKey(eks, now) + if err != nil { + return false, fmt.Errorf("failed to create %s exchange key: %w", eks.tool.Info.Name, err) + } + changed = true + } + } + + // export most recent keys to HubStatus + if changed || len(newStatus.Keys) == 0 { + // reset + newStatus.Keys = make(map[string]*hub.Key) + + // find longest valid key for every provided scheme + for _, eks := range provideExchKeySchemes { + // find key of scheme that is valid the longest + longestValid := &ExchKey{ + Expires: now, + } + for _, exchKey := range id.ExchKeys { + if exchKey.key != nil && + exchKey.key.Scheme == eks.id && + exchKey.Expires.After(longestValid.Expires) { + longestValid = exchKey + } + } + + // check result + if longestValid.key == nil { + log.Warningf("spn/cabin: could not find export candidate for exchange key scheme %s", eks.id) + continue + } + + // export + hubKey, err := longestValid.toHubKey() + if err != nil { + return false, fmt.Errorf("failed to export %s exchange key: %w", longestValid.tool.Info.Name, err) + } + // add + newStatus.Keys[longestValid.key.ID] = hubKey + } + } + + return changed, nil +} + +func (id *Identity) createExchKey(eks *providedExchKeyScheme, now time.Time) error { + // get ID + var keyID string + for i := 0; i < 1000000; i++ { // not forever + // generate new ID + b, err := rng.Bytes(3) + if err != nil { + return fmt.Errorf("failed to get random data for key ID: %w", err) + } + keyID = base64.RawURLEncoding.EncodeToString(b) + _, exists := id.ExchKeys[keyID] + if !exists { + break + } + } + if keyID == "" { + return errors.New("unable to find available exchange key ID") + } + + // generate key + signet := jess.NewSignetBase(eks.tool) + signet.ID = keyID + // TODO: use security level for key generation + if err := signet.GenerateKey(); err != nil { + return fmt.Errorf("failed to get new exchange key: %w", err) + } + + // add to key map + id.ExchKeys[keyID] = &ExchKey{ + Created: now, + Expires: now.Add(validFor), + key: signet, + tool: eks.tool, + } + return nil +} diff --git a/spn/cabin/keys_test.go b/spn/cabin/keys_test.go new file mode 100644 index 00000000..c1622fe6 --- /dev/null +++ b/spn/cabin/keys_test.go @@ -0,0 +1,43 @@ +package cabin + +import ( + "testing" + "time" + + "github.com/safing/portmaster/spn/conf" +) + +func TestKeyMaintenance(t *testing.T) { + t.Parallel() + + id, err := CreateIdentity(module.Ctx, conf.MainMapName) + if err != nil { + t.Fatal(err) + } + + iterations := 1000 + changeCnt := 0 + + now := time.Now() + for i := 0; i < iterations; i++ { + changed, err := id.MaintainExchKeys(id.Hub.Status, now) + if err != nil { + t.Fatal(err) + } + if changed { + changeCnt++ + t.Logf("===== exchange keys updated at %s:\n", now) + for keyID, exchKey := range id.ExchKeys { + t.Logf("[%s] %s %v\n", exchKey.Created, keyID, exchKey.key) + } + } + now = now.Add(1 * time.Hour) + } + + if iterations/changeCnt > 25 { // one new key every 24 hours/ticks + t.Fatal("more changes than expected") + } + if len(id.ExchKeys) > 17 { // one new key every day for two weeks + 3 in use + t.Fatal("more keys than expected") + } +} diff --git a/spn/cabin/module.go b/spn/cabin/module.go new file mode 100644 index 00000000..8644502f --- /dev/null +++ b/spn/cabin/module.go @@ -0,0 +1,26 @@ +package cabin + +import ( + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/conf" +) + +var module *modules.Module + +func init() { + module = modules.Register("cabin", prep, nil, nil, "base", "rng") +} + +func prep() error { + if err := initProvidedExchKeySchemes(); err != nil { + return err + } + + if conf.PublicHub() { + if err := prepPublicHubConfig(); err != nil { + return err + } + } + + return nil +} diff --git a/spn/cabin/module_test.go b/spn/cabin/module_test.go new file mode 100644 index 00000000..c2d66ed1 --- /dev/null +++ b/spn/cabin/module_test.go @@ -0,0 +1,13 @@ +package cabin + +import ( + "testing" + + "github.com/safing/portmaster/service/core/pmtesting" + "github.com/safing/portmaster/spn/conf" +) + +func TestMain(m *testing.M) { + conf.EnablePublicHub(true) + pmtesting.TestMain(m, module) +} diff --git a/spn/cabin/verification.go b/spn/cabin/verification.go new file mode 100644 index 00000000..07a993ea --- /dev/null +++ b/spn/cabin/verification.go @@ -0,0 +1,157 @@ +package cabin + +import ( + "crypto/subtle" + "errors" + "fmt" + + "github.com/safing/jess" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/hub" +) + +var ( + verificationChallengeSize = 32 + verificationChallengeMinSize = 16 + verificationSigningSuite = jess.SuiteSignV1 + verificationRequirements = jess.NewRequirements(). + Remove(jess.Confidentiality). + Remove(jess.Integrity). + Remove(jess.RecipientAuthentication) +) + +// Verification is used to verify certain aspects of another Hub. +type Verification struct { + // Challenge is a random value chosen by the client. + Challenge []byte `json:"c"` + // Purpose defines the purpose of the verification. Protects against using verification for other purposes. + Purpose string `json:"p"` + // ClientReference is an optional field for exchanging metadata about the client. Protects against forwarding/relay attacks. + ClientReference string `json:"cr"` + // ServerReference is an optional field for exchanging metadata about the server. Protects against forwarding/relay attacks. + ServerReference string `json:"sr"` +} + +// CreateVerificationRequest creates a new verification request with the given +// purpose and references. +func CreateVerificationRequest(purpose, clientReference, serverReference string) (v *Verification, request []byte, err error) { + // Generate random challenge. + challenge, err := rng.Bytes(verificationChallengeSize) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate challenge: %w", err) + } + + // Create verification object. + v = &Verification{ + Purpose: purpose, + ClientReference: clientReference, + Challenge: challenge, + } + + // Serialize verification. + request, err = dsd.Dump(v, dsd.JSON) + if err != nil { + return nil, nil, fmt.Errorf("failed to serialize verification request: %w", err) + } + + // The server reference is not sent to the server, but needs to be supplied + // by the server. + v.ServerReference = serverReference + + return v, request, nil +} + +// SignVerificationRequest sign a verification request. +// The purpose and references must match the request, else the verification +// will fail. +func (id *Identity) SignVerificationRequest(request []byte, purpose, clientReference, serverReference string) (response []byte, err error) { + // Parse request. + v := new(Verification) + _, err = dsd.Load(request, v) + if err != nil { + return nil, fmt.Errorf("failed to parse request: %w", err) + } + + // Validate request. + if len(v.Challenge) < verificationChallengeMinSize { + return nil, errors.New("challenge too small") + } + if v.Purpose != purpose { + return nil, errors.New("purpose mismatch") + } + if v.ClientReference != clientReference { + return nil, errors.New("client reference mismatch") + } + + // Assign server reference and serialize. + v.ServerReference = serverReference + dataToSign, err := dsd.Dump(v, dsd.JSON) + if err != nil { + return nil, fmt.Errorf("failed to serialize verification response: %w", err) + } + + // Sign response. + e := jess.NewUnconfiguredEnvelope() + e.SuiteID = verificationSigningSuite + e.Senders = []*jess.Signet{id.Signet} + jession, err := e.Correspondence(nil) + if err != nil { + return nil, fmt.Errorf("failed to setup signer: %w", err) + } + letter, err := jession.Close(dataToSign) + if err != nil { + return nil, fmt.Errorf("failed to sign: %w", err) + } + + // Serialize and return. + signedResponse, err := letter.ToDSD(dsd.JSON) + if err != nil { + return nil, fmt.Errorf("failed to serialize letter: %w", err) + } + + return signedResponse, nil +} + +// Verify verifies the verification response and checks if everything is valid. +func (v *Verification) Verify(response []byte, h *hub.Hub) error { + // Parse response. + letter, err := jess.LetterFromDSD(response) + if err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Verify response. + responseData, err := letter.Open( + verificationRequirements, + &hub.SingleTrustStore{ + Signet: h.PublicKey, + }, + ) + if err != nil { + return fmt.Errorf("failed to verify response: %w", err) + } + + // Parse verified response. + responseV := new(Verification) + _, err = dsd.Load(responseData, responseV) + if err != nil { + return fmt.Errorf("failed to parse verified response: %w", err) + } + + // Validate request. + if subtle.ConstantTimeCompare(v.Challenge, responseV.Challenge) != 1 { + return errors.New("challenge mismatch") + } + if subtle.ConstantTimeCompare([]byte(v.Purpose), []byte(responseV.Purpose)) != 1 { + return errors.New("purpose mismatch") + } + if subtle.ConstantTimeCompare([]byte(v.ClientReference), []byte(responseV.ClientReference)) != 1 { + return errors.New("client reference mismatch") + } + if subtle.ConstantTimeCompare([]byte(v.ServerReference), []byte(responseV.ServerReference)) != 1 { + return errors.New("server reference mismatch") + } + + return nil +} diff --git a/spn/cabin/verification_test.go b/spn/cabin/verification_test.go new file mode 100644 index 00000000..cb743a3d --- /dev/null +++ b/spn/cabin/verification_test.go @@ -0,0 +1,127 @@ +package cabin + +import ( + "fmt" + "testing" +) + +func TestVerification(t *testing.T) { + t.Parallel() + + id, err := CreateIdentity(module.Ctx, "test") + if err != nil { + t.Fatal(err) + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "b", "c", + "", "", "", nil, + ); err != nil { + t.Fatal(err) + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "x", "b", "c", + "", "", "", nil, + ); err == nil { + t.Fatal("should fail on purpose mismatch") + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "x", "c", + "", "", "", nil, + ); err == nil { + t.Fatal("should fail on client ref mismatch") + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "b", "x", + "", "", "", nil, + ); err == nil { + t.Fatal("should fail on server ref mismatch") + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "b", "c", + "x", "", "", nil, + ); err == nil { + t.Fatal("should fail on purpose mismatch") + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "b", "c", + "", "x", "", nil, + ); err == nil { + t.Fatal("should fail on client ref mismatch") + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "b", "c", + "", "", "x", nil, + ); err == nil { + t.Fatal("should fail on server ref mismatch") + } + + if err := testVerificationWith( + t, id, + "a", "b", "c", + "a", "b", "c", + "", "", "", []byte{1, 2, 3, 4}, + ); err == nil { + t.Fatal("should fail on challenge mismatch") + } +} + +func testVerificationWith( + t *testing.T, id *Identity, + purpose1, clientRef1, serverRef1 string, //nolint:unparam + purpose2, clientRef2, serverRef2 string, + mitmPurpose, mitmClientRef, mitmServerRef string, + mitmChallenge []byte, +) error { + t.Helper() + + v, request, err := CreateVerificationRequest(purpose1, clientRef1, serverRef1) + if err != nil { + return fmt.Errorf("failed to create verification request: %w", err) + } + + response, err := id.SignVerificationRequest(request, purpose2, clientRef2, serverRef2) + if err != nil { + return fmt.Errorf("failed to sign verification response: %w", err) + } + + if mitmPurpose != "" { + v.Purpose = mitmPurpose + } + if mitmClientRef != "" { + v.ClientReference = mitmClientRef + } + if mitmServerRef != "" { + v.ServerReference = mitmServerRef + } + if mitmChallenge != nil { + v.Challenge = mitmChallenge + } + + err = v.Verify(response, id.Hub) + if err != nil { + return fmt.Errorf("failed to verify: %w", err) + } + + return nil +} diff --git a/spn/captain/api.go b/spn/captain/api.go new file mode 100644 index 00000000..dcc412d8 --- /dev/null +++ b/spn/captain/api.go @@ -0,0 +1,68 @@ +package captain + +import ( + "errors" + "fmt" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/modules" +) + +const ( + apiPathForSPNReInit = "spn/reinit" +) + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: apiPathForSPNReInit, + Write: api.PermitAdmin, + // BelongsTo: module, // Do not attach to module, as this must run outside of the module. + ActionFunc: handleReInit, + Name: "Re-initialize SPN", + Description: "Stops the SPN, resets all caches and starts it again. The SPN account and settings are not changed.", + }); err != nil { + return err + } + + return nil +} + +func handleReInit(ar *api.Request) (msg string, err error) { + // Disable module and check + changed := module.Disable() + if !changed { + return "", errors.New("can only re-initialize when the SPN is enabled") + } + + // Run module manager. + err = modules.ManageModules() + if err != nil { + return "", fmt.Errorf("failed to stop SPN: %w", err) + } + + // Delete SPN cache. + db := database.NewInterface(&database.Options{ + Local: true, + Internal: true, + }) + deletedRecords, err := db.Purge(ar.Context(), query.New("cache:spn/")) + if err != nil { + return "", fmt.Errorf("failed to delete SPN cache: %w", err) + } + + // Enable module. + module.Enable() + + // Run module manager. + err = modules.ManageModules() + if err != nil { + return "", fmt.Errorf("failed to start SPN after cache reset: %w", err) + } + + return fmt.Sprintf( + "Completed SPN re-initialization and deleted %d cache records in the process.", + deletedRecords, + ), nil +} diff --git a/spn/captain/bootstrap.go b/spn/captain/bootstrap.go new file mode 100644 index 00000000..c7096116 --- /dev/null +++ b/spn/captain/bootstrap.go @@ -0,0 +1,152 @@ +package captain + +import ( + "errors" + "flag" + "fmt" + "io/fs" + "os" + + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/navigator" +) + +// BootstrapFile is used for sideloading bootstrap data. +type BootstrapFile struct { + Main BootstrapFileEntry +} + +// BootstrapFileEntry is the bootstrap data structure for one map. +type BootstrapFileEntry struct { + Hubs []string +} + +var ( + bootstrapHubFlag string + bootstrapFileFlag string +) + +func init() { + flag.StringVar(&bootstrapHubFlag, "bootstrap-hub", "", "transport address of hub for bootstrapping with the hub ID in the fragment") + flag.StringVar(&bootstrapFileFlag, "bootstrap-file", "", "bootstrap file containing bootstrap hubs - will be initialized if running a public hub and it doesn't exist") +} + +// prepBootstrapHubFlag checks the bootstrap-hub argument if it is valid. +func prepBootstrapHubFlag() error { + if bootstrapHubFlag != "" { + _, _, _, err := hub.ParseBootstrapHub(bootstrapHubFlag) + return err + } + return nil +} + +// processBootstrapHubFlag processes the bootstrap-hub argument. +func processBootstrapHubFlag() error { + if bootstrapHubFlag != "" { + return navigator.Main.AddBootstrapHubs([]string{bootstrapHubFlag}) + } + return nil +} + +// processBootstrapFileFlag processes the bootstrap-file argument. +func processBootstrapFileFlag() error { + if bootstrapFileFlag == "" { + return nil + } + + _, err := os.Stat(bootstrapFileFlag) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return createBootstrapFile(bootstrapFileFlag) + } + return fmt.Errorf("failed to access bootstrap hub file: %w", err) + } + + return loadBootstrapFile(bootstrapFileFlag) +} + +// bootstrapWithUpdates loads bootstrap hubs from the updates server and imports them. +func bootstrapWithUpdates() error { + if bootstrapFileFlag != "" { + return errors.New("using the bootstrap-file argument disables bootstrapping via the update system") + } + + return updateSPNIntel(module.Ctx, nil) +} + +// loadBootstrapFile loads a file with bootstrap hub entries and imports them. +func loadBootstrapFile(filename string) (err error) { + // Load bootstrap file from disk and parse it. + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to load bootstrap file: %w", err) + } + bootstrapFile := &BootstrapFile{} + _, err = dsd.Load(data, bootstrapFile) + if err != nil { + return fmt.Errorf("failed to parse bootstrap file: %w", err) + } + if len(bootstrapFile.Main.Hubs) == 0 { + return errors.New("bootstrap holds no hubs for main map") + } + + // Add Hubs to map. + err = navigator.Main.AddBootstrapHubs(bootstrapFile.Main.Hubs) + if err == nil { + log.Infof("spn/captain: loaded bootstrap file %s", filename) + } + return err +} + +// createBootstrapFile save a bootstrap hub file with an entry of the public identity. +func createBootstrapFile(filename string) error { + if !conf.PublicHub() { + log.Infof("spn/captain: skipped writing a bootstrap hub file, as this is not a public hub") + return nil + } + + // create bootstrap hub + if len(publicIdentity.Hub.Info.Transports) == 0 { + return errors.New("public identity has no transports available") + } + // parse first transport + t, err := hub.ParseTransport(publicIdentity.Hub.Info.Transports[0]) + if err != nil { + return fmt.Errorf("failed to parse transport of public identity: %w", err) + } + // add IP address + switch { + case publicIdentity.Hub.Info.IPv4 != nil: + t.Domain = publicIdentity.Hub.Info.IPv4.String() + case publicIdentity.Hub.Info.IPv6 != nil: + t.Domain = "[" + publicIdentity.Hub.Info.IPv6.String() + "]" + default: + return errors.New("public identity has no IP address available") + } + // add Hub ID + t.Option = publicIdentity.Hub.ID + // put together + bs := &BootstrapFile{ + Main: BootstrapFileEntry{ + Hubs: []string{t.String()}, + }, + } + + // serialize + fileData, err := dsd.Dump(bs, dsd.JSON) + if err != nil { + return err + } + + // save to disk + err = os.WriteFile(filename, fileData, 0o0664) //nolint:gosec // Should be able to be read by others. + if err != nil { + return err + } + + log.Infof("spn/captain: created bootstrap file %s", filename) + return nil +} diff --git a/spn/captain/client.go b/spn/captain/client.go new file mode 100644 index 00000000..b30e4e98 --- /dev/null +++ b/spn/captain/client.go @@ -0,0 +1,506 @@ +package captain + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/notifications" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/crew" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/terminal" +) + +var ( + ready = abool.New() + + spnLoginButton = notifications.Action{ + Text: "Login", + Type: notifications.ActionTypeOpenPage, + Payload: "spn", + } + spnOpenAccountPage = notifications.Action{ + Text: "Open Account Page", + Type: notifications.ActionTypeOpenURL, + Payload: "https://account.safing.io", + } +) + +// ClientReady signifies if the SPN client is fully ready to handle connections. +func ClientReady() bool { + return ready.IsSet() +} + +type ( + clientComponentFunc func(ctx context.Context) clientComponentResult + clientComponentResult uint8 +) + +const ( + clientResultOk clientComponentResult = iota // Continue and clean module status. + clientResultRetry // Go back to start of current step, don't clear module status. + clientResultReconnect // Stop current connection and start from zero. + clientResultShutdown // SPN Module is shutting down. +) + +var ( + clientNetworkChangedFlag = netenv.GetNetworkChangedFlag() + clientIneligibleAccountUpdateDelay = 1 * time.Minute + clientRetryConnectBackoffDuration = 5 * time.Second + clientInitialHealthCheckDelay = 10 * time.Second + clientHealthCheckTickDuration = 1 * time.Minute + clientHealthCheckTickDurationSleepMode = 5 * time.Minute + clientHealthCheckTimeout = 15 * time.Second + + clientHealthCheckTrigger = make(chan struct{}, 1) + lastHealthCheck time.Time +) + +func triggerClientHealthCheck() { + select { + case clientHealthCheckTrigger <- struct{}{}: + default: + } +} + +func clientManager(ctx context.Context) error { + defer func() { + ready.UnSet() + netenv.ConnectedToSPN.UnSet() + resetSPNStatus(StatusDisabled, true) + module.Resolve("") + clientStopHomeHub(ctx) + }() + + module.Hint( + "spn:establishing-home-hub", + "Connecting to SPN...", + "Connecting to the SPN network is in progress.", + ) + + // TODO: When we are starting and the SPN module is faster online than the + // nameserver, then updating the account will fail as the DNS query is + // redirected to a closed port. + // We also can't add the nameserver as a module dependency, as the nameserver + // is not part of the server. + select { + case <-time.After(1 * time.Second): + case <-ctx.Done(): + return nil + } + + healthCheckTicker := module.NewSleepyTicker(clientHealthCheckTickDuration, clientHealthCheckTickDurationSleepMode) + +reconnect: + for { + // Check if we are shutting down. + select { + case <-ctx.Done(): + return nil + default: + } + + // Reset SPN status. + if ready.SetToIf(true, false) { + netenv.ConnectedToSPN.UnSet() + log.Info("spn/captain: client not ready") + } + resetSPNStatus(StatusConnecting, true) + + // Check everything and connect to the SPN. + for _, clientFunc := range []clientComponentFunc{ + clientStopHomeHub, + clientCheckNetworkReady, + clientCheckAccountAndTokens, + clientConnectToHomeHub, + clientSetActiveConnectionStatus, + } { + switch clientFunc(ctx) { + case clientResultOk: + // Continue + case clientResultRetry, clientResultReconnect: + // Wait for a short time to not loop too quickly. + select { + case <-time.After(clientRetryConnectBackoffDuration): + continue reconnect + case <-ctx.Done(): + return nil + } + case clientResultShutdown: + return nil + } + } + + log.Info("spn/captain: client is ready") + ready.Set() + netenv.ConnectedToSPN.Set() + + module.TriggerEvent(SPNConnectedEvent, nil) + module.StartWorker("update quick setting countries", navigator.Main.UpdateConfigQuickSettings) + + // Reset last health check value, as we have just connected. + lastHealthCheck = time.Now() + + // Back off before starting initial health checks. + select { + case <-time.After(clientInitialHealthCheckDelay): + case <-ctx.Done(): + return nil + } + + for { + // Check health of the current SPN connection and monitor the user status. + maintainers: + for _, clientFunc := range []clientComponentFunc{ + clientCheckHomeHubConnection, + clientCheckAccountAndTokens, + clientSetActiveConnectionStatus, + } { + switch clientFunc(ctx) { + case clientResultOk: + // Continue + case clientResultRetry: + // Abort and wait for the next run. + break maintainers + case clientResultReconnect: + continue reconnect + case clientResultShutdown: + return nil + } + } + + // Wait for signal to run maintenance again. + select { + case <-healthCheckTicker.Wait(): + case <-clientHealthCheckTrigger: + case <-crew.ConnectErrors(): + case <-clientNetworkChangedFlag.Signal(): + clientNetworkChangedFlag.Refresh() + case <-ctx.Done(): + return nil + } + } + } +} + +func clientCheckNetworkReady(ctx context.Context) clientComponentResult { + // Check if we are online enough for connecting. + switch netenv.GetOnlineStatus() { //nolint:exhaustive + case netenv.StatusOffline, + netenv.StatusLimited: + select { + case <-ctx.Done(): + return clientResultShutdown + case <-time.After(1 * time.Second): + return clientResultRetry + } + } + + return clientResultOk +} + +// DisableAccount disables using any account related SPN functionality. +// Attempts to use the same will result in errors. +var DisableAccount bool + +func clientCheckAccountAndTokens(ctx context.Context) clientComponentResult { + if DisableAccount { + return clientResultOk + } + + // Get SPN user. + user, err := access.GetUser() + if err != nil && !errors.Is(err, access.ErrNotLoggedIn) { + notifications.NotifyError( + "spn:failed-to-get-user", + "SPN Internal Error", + `Please restart Portmaster.`, + // TODO: Add restart button. + // TODO: Use special UI restart action in order to reload UI on restart. + ).AttachToModule(module) + resetSPNStatus(StatusFailed, true) + log.Errorf("spn/captain: client internal error: %s", err) + return clientResultReconnect + } + + // Check if user is logged in. + if user == nil || !user.IsLoggedIn() { + notifications.NotifyWarn( + "spn:not-logged-in", + "SPN Login Required", + `Please log in to access the SPN.`, + spnLoginButton, + ).AttachToModule(module) + resetSPNStatus(StatusFailed, true) + log.Warningf("spn/captain: enabled but not logged in") + return clientResultReconnect + } + + // Check if user is eligible. + if !user.MayUseTheSPN() { + // Update user in case there was a change. + // Only update here if we need to - there is an update task in the access + // module for periodic updates. + if time.Now().Add(-clientIneligibleAccountUpdateDelay).After(time.Unix(user.Meta().Modified, 0)) { + _, _, err := access.UpdateUser() + if err != nil { + notifications.NotifyError( + "spn:failed-to-update-user", + "SPN Account Server Error", + fmt.Sprintf(`The status of your SPN account could not be updated: %s`, err), + ).AttachToModule(module) + resetSPNStatus(StatusFailed, true) + log.Errorf("spn/captain: failed to update ineligible account: %s", err) + return clientResultReconnect + } + } + + // Check if user is eligible after a possible update. + if !user.MayUseTheSPN() { + + // If package is generally valid, then the current package does not have access to the SPN. + if user.MayUse("") { + notifications.NotifyError( + "spn:package-not-eligible", + "SPN Not Included In Package", + "Your current Portmaster Package does not include access to the SPN. Please upgrade your package on the Account Page.", + spnOpenAccountPage, + ).AttachToModule(module) + resetSPNStatus(StatusFailed, true) + return clientResultReconnect + } + + // Otherwise, include the message from the user view. + message := "There is an issue with your Portmaster Package. Please check the Account Page." + if user.View != nil && user.View.Message != "" { + message = user.View.Message + } + notifications.NotifyError( + "spn:subscription-inactive", + "Portmaster Package Issue", + "Cannot enable SPN: "+message, + spnOpenAccountPage, + ).AttachToModule(module) + resetSPNStatus(StatusFailed, true) + return clientResultReconnect + } + } + + // Check if we have enough tokens. + if access.ShouldRequest(access.ExpandAndConnectZones) { + err := access.UpdateTokens() + if err != nil { + log.Errorf("spn/captain: failed to get tokens: %s", err) + + // There was an error updating the account. + // Check if we have enough tokens to continue anyway. + regular, _ := access.GetTokenAmount(access.ExpandAndConnectZones) + if regular == 0 /* && fallback == 0 */ { // TODO: Add fallback token check when fallback was tested on servers. + notifications.NotifyError( + "spn:tokens-exhausted", + "SPN Access Tokens Exhausted", + `The Portmaster failed to get new access tokens to access the SPN. The Portmaster will automatically retry to get new access tokens.`, + ).AttachToModule(module) + resetSPNStatus(StatusFailed, false) + } + return clientResultRetry + } + } + + return clientResultOk +} + +func clientStopHomeHub(ctx context.Context) clientComponentResult { + // Don't use the context in this function, as it will likely be canceled + // already and would disrupt any context usage in here. + + // Get crane connecting to home. + home, _ := navigator.Main.GetHome() + if home == nil { + return clientResultOk + } + crane := docks.GetAssignedCrane(home.Hub.ID) + if crane == nil { + return clientResultOk + } + + // Stop crane and all connected terminals. + crane.Stop(nil) + return clientResultOk +} + +func clientConnectToHomeHub(ctx context.Context) clientComponentResult { + err := establishHomeHub(ctx) + if err != nil { + log.Errorf("spn/captain: failed to establish connection to home hub: %s", err) + resetSPNStatus(StatusFailed, true) + + switch { + case errors.Is(err, ErrAllHomeHubsExcluded): + notifications.NotifyError( + "spn:all-home-hubs-excluded", + "All Home Nodes Excluded", + "Your current Home Node Rules exclude all available and eligible SPN Nodes. Please change your rules to allow for at least one available and eligible Home Node.", + notifications.Action{ + Text: "Configure", + Type: notifications.ActionTypeOpenSetting, + Payload: ¬ifications.ActionTypeOpenSettingPayload{ + Key: CfgOptionHomeHubPolicyKey, + }, + }, + ).AttachToModule(module) + + case errors.Is(err, ErrReInitSPNSuggested): + notifications.NotifyError( + "spn:cannot-bootstrap", + "SPN Cannot Bootstrap", + "The local state of the SPN network is likely outdated. Portmaster was not able to identify a server to connect to. Please re-initialize the SPN using the tools menu or the button on the notification.", + notifications.Action{ + ID: "re-init", + Text: "Re-Init SPN", + Type: notifications.ActionTypeWebhook, + Payload: ¬ifications.ActionTypeWebhookPayload{ + URL: apiPathForSPNReInit, + ResultAction: "display", + }, + }, + ).AttachToModule(module) + + default: + notifications.NotifyWarn( + "spn:home-hub-failure", + "SPN Failed to Connect", + fmt.Sprintf("Failed to connect to a home hub: %s. The Portmaster will retry to connect automatically.", err), + ).AttachToModule(module) + } + + return clientResultReconnect + } + + // Log new connection. + home, _ := navigator.Main.GetHome() + if home != nil { + log.Infof("spn/captain: established new home %s", home.Hub) + } + + return clientResultOk +} + +func clientSetActiveConnectionStatus(ctx context.Context) clientComponentResult { + // Get current home. + home, homeTerminal := navigator.Main.GetHome() + if home == nil || homeTerminal == nil { + return clientResultReconnect + } + + // Resolve any connection error. + module.Resolve("") + + // Update SPN Status with connection information, if not already correctly set. + spnStatus.Lock() + defer spnStatus.Unlock() + + if spnStatus.Status != StatusConnected || spnStatus.HomeHubID != home.Hub.ID { + // Fill connection status data. + spnStatus.Status = StatusConnected + spnStatus.HomeHubID = home.Hub.ID + spnStatus.HomeHubName = home.Hub.Info.Name + + connectedIP, _, err := netutils.IPPortFromAddr(homeTerminal.RemoteAddr()) + if err != nil { + spnStatus.ConnectedIP = homeTerminal.RemoteAddr().String() + } else { + spnStatus.ConnectedIP = connectedIP.String() + } + spnStatus.ConnectedTransport = homeTerminal.Transport().String() + + geoLoc := home.GetLocation(connectedIP) + if geoLoc != nil { + spnStatus.ConnectedCountry = &geoLoc.Country + } + + now := time.Now() + spnStatus.ConnectedSince = &now + + // Push new status. + pushSPNStatusUpdate() + } + + return clientResultOk +} + +func clientCheckHomeHubConnection(ctx context.Context) clientComponentResult { + // Check the status of the Home Hub. + home, homeTerminal := navigator.Main.GetHome() + if home == nil || homeTerminal == nil || homeTerminal.IsBeingAbandoned() { + return clientResultReconnect + } + + // Get crane controller for health check. + crane := docks.GetAssignedCrane(home.Hub.ID) + if crane == nil { + log.Errorf("spn/captain: could not find home hub crane for health check") + return clientResultOk + } + + // Ping home hub. + latency, tErr := pingHome(ctx, crane.Controller, clientHealthCheckTimeout) + if tErr != nil { + log.Warningf("spn/captain: failed to ping home hub: %s", tErr) + + // Prepare to reconnect to the network. + + // Reset all failing states, as these might have been caused by the failing home hub. + navigator.Main.ResetFailingStates(ctx) + + // If the last health check is clearly too long ago, assume that the device was sleeping and do not set the home node to failing yet. + if time.Since(lastHealthCheck) > clientHealthCheckTickDuration+ + clientHealthCheckTickDurationSleepMode+ + (clientHealthCheckTimeout*2) { + return clientResultReconnect + } + + // Mark the home hub itself as failing, as we want to try to connect to somewhere else. + home.MarkAsFailingFor(5 * time.Minute) + + return clientResultReconnect + } + lastHealthCheck = time.Now() + + log.Debugf("spn/captain: pinged home hub in %s", latency) + return clientResultOk +} + +func pingHome(ctx context.Context, t terminal.Terminal, timeout time.Duration) (latency time.Duration, err *terminal.Error) { + started := time.Now() + + // Start ping operation. + pingOp, tErr := crew.NewPingOp(t) + if tErr != nil { + return 0, tErr + } + + // Wait for response. + select { + case <-ctx.Done(): + return 0, terminal.ErrCanceled + case <-time.After(timeout): + return 0, terminal.ErrTimeout + case result := <-pingOp.Result: + if result.Is(terminal.ErrExplicitAck) { + return time.Since(started), nil + } + if result.IsOK() { + return 0, result.Wrap("unexpected response") + } + return 0, result + } +} diff --git a/spn/captain/config.go b/spn/captain/config.go new file mode 100644 index 00000000..09e6f490 --- /dev/null +++ b/spn/captain/config.go @@ -0,0 +1,253 @@ +package captain + +import ( + "sync" + + "github.com/safing/portbase/config" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/navigator" +) + +var ( + // CfgOptionEnableSPNKey is the configuration key for the SPN module. + CfgOptionEnableSPNKey = "spn/enable" + cfgOptionEnableSPNOrder = 128 + + // CfgOptionHomeHubPolicyKey is the configuration key for the SPN home policy. + CfgOptionHomeHubPolicyKey = "spn/homePolicy" + cfgOptionHomeHubPolicy config.StringArrayOption + cfgOptionHomeHubPolicyOrder = 145 + + // CfgOptionDNSExitHubPolicyKey is the configuration key for the SPN DNS exit policy. + CfgOptionDNSExitHubPolicyKey = "spn/dnsExitPolicy" + cfgOptionDNSExitHubPolicy config.StringArrayOption + cfgOptionDNSExitHubPolicyOrder = 148 + + // CfgOptionUseCommunityNodesKey is the configuration key for whether to use community nodes. + CfgOptionUseCommunityNodesKey = "spn/useCommunityNodes" + cfgOptionUseCommunityNodes config.BoolOption + cfgOptionUseCommunityNodesOrder = 149 + + // NonCommunityVerifiedOwners holds a list of verified owners that are not + // considered "community". + NonCommunityVerifiedOwners = []string{"Safing"} + + // CfgOptionTrustNodeNodesKey is the configuration key for whether additional trusted nodes. + CfgOptionTrustNodeNodesKey = "spn/trustNodes" + cfgOptionTrustNodeNodes config.StringArrayOption + cfgOptionTrustNodeNodesOrder = 150 + + // Special Access Code. + cfgOptionSpecialAccessCodeKey = "spn/specialAccessCode" + cfgOptionSpecialAccessCodeDefault = "none" + cfgOptionSpecialAccessCode config.StringOption //nolint:unused // Linter, you drunk? + cfgOptionSpecialAccessCodeOrder = 160 + + // IPv6 must be global and accessible. + cfgOptionBindToAdvertisedKey = "spn/publicHub/bindToAdvertised" + cfgOptionBindToAdvertised config.BoolOption + cfgOptionBindToAdvertisedDefault = false + cfgOptionBindToAdvertisedOrder = 161 + + // Config options for use. + cfgOptionRoutingAlgorithm config.StringOption +) + +func prepConfig() error { + // Home Node Rules + err := config.Register(&config.Option{ + Name: "Home Node Rules", + Key: CfgOptionHomeHubPolicyKey, + Description: `Customize which countries should or should not be used for your Home Node. The Home Node is your entry into the SPN. You connect directly to it and all your connections are routed through it. + +By default, the Portmaster tries to choose the nearest node as your Home Node in order to reduce your exposure to the open Internet. + +Reconnect to the SPN in order to apply new rules.`, + Help: profile.SPNRulesHelp, + Sensitive: true, + OptType: config.OptTypeStringArray, + RequiresRestart: true, + ExpertiseLevel: config.ExpertiseLevelExpert, + DefaultValue: []string{}, + Annotations: config.Annotations{ + config.CategoryAnnotation: "Routing", + config.DisplayOrderAnnotation: cfgOptionHomeHubPolicyOrder, + config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, + config.QuickSettingsAnnotation: profile.SPNRulesQuickSettings, + endpoints.EndpointListVerdictNamesAnnotation: profile.SPNRulesVerdictNames, + }, + ValidationRegex: endpoints.ListEntryValidationRegex, + ValidationFunc: endpoints.ValidateEndpointListConfigOption, + }) + if err != nil { + return err + } + cfgOptionHomeHubPolicy = config.Concurrent.GetAsStringArray(CfgOptionHomeHubPolicyKey, []string{}) + + // DNS Exit Node Rules + err = config.Register(&config.Option{ + Name: "DNS Exit Node Rules", + Key: CfgOptionDNSExitHubPolicyKey, + Description: `Customize which countries should or should not be used as DNS Exit Nodes. + +By default, the Portmaster will exit DNS requests directly at your Home Node in order to keep them fast and close to your location. This is important, as DNS resolution often takes your approximate location into account when deciding which optimized DNS records are returned to you. As the Portmaster encrypts your DNS requests by default, you effectively gain a two-hop security level for your DNS requests in order to protect your privacy. + +This setting mainly exists for when you need to simulate your presence in another location on a lower level too. This might be necessary to defeat more intelligent geo-blocking systems.`, + Help: profile.SPNRulesHelp, + Sensitive: true, + OptType: config.OptTypeStringArray, + RequiresRestart: true, + ExpertiseLevel: config.ExpertiseLevelExpert, + DefaultValue: []string{}, + Annotations: config.Annotations{ + config.CategoryAnnotation: "Routing", + config.DisplayOrderAnnotation: cfgOptionDNSExitHubPolicyOrder, + config.DisplayHintAnnotation: endpoints.DisplayHintEndpointList, + config.QuickSettingsAnnotation: profile.SPNRulesQuickSettings, + endpoints.EndpointListVerdictNamesAnnotation: profile.SPNRulesVerdictNames, + }, + ValidationRegex: endpoints.ListEntryValidationRegex, + ValidationFunc: endpoints.ValidateEndpointListConfigOption, + }) + if err != nil { + return err + } + cfgOptionDNSExitHubPolicy = config.Concurrent.GetAsStringArray(CfgOptionDNSExitHubPolicyKey, []string{}) + + err = config.Register(&config.Option{ + Name: "Use Community Nodes", + Key: CfgOptionUseCommunityNodesKey, + Description: "Use nodes (servers) not operated by Safing themselves. The use of community nodes is recommended as it diversifies the ownership of the nodes you use for your connections and further strengthens your privacy. Plain connections (eg. http, smtp, ...) will never exit via community nodes, making this setting safe to use.", + Sensitive: true, + OptType: config.OptTypeBool, + RequiresRestart: true, + DefaultValue: true, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: cfgOptionUseCommunityNodesOrder, + config.CategoryAnnotation: "Routing", + }, + }) + if err != nil { + return err + } + cfgOptionUseCommunityNodes = config.Concurrent.GetAsBool(CfgOptionUseCommunityNodesKey, true) + + err = config.Register(&config.Option{ + Name: "Trust Nodes", + Key: CfgOptionTrustNodeNodesKey, + Description: "Specify which community nodes to additionally trust. These nodes may then also be used as a Home Node, as well as an Exit Node for unencrypted connections.", + Help: "You can specify nodes by their ID or their verified operator.", + Sensitive: true, + OptType: config.OptTypeStringArray, + ExpertiseLevel: config.ExpertiseLevelExpert, + DefaultValue: []string{}, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: cfgOptionTrustNodeNodesOrder, + config.CategoryAnnotation: "Routing", + }, + }) + if err != nil { + return err + } + cfgOptionTrustNodeNodes = config.Concurrent.GetAsStringArray(CfgOptionTrustNodeNodesKey, []string{}) + + err = config.Register(&config.Option{ + Name: "Special Access Code", + Key: cfgOptionSpecialAccessCodeKey, + Description: "Special Access Codes grant access to the SPN for testing or evaluation purposes.", + Sensitive: true, + OptType: config.OptTypeString, + DefaultValue: cfgOptionSpecialAccessCodeDefault, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: cfgOptionSpecialAccessCodeOrder, + config.CategoryAnnotation: "Advanced", + }, + }) + if err != nil { + return err + } + cfgOptionSpecialAccessCode = config.Concurrent.GetAsString(cfgOptionSpecialAccessCodeKey, "") + + if conf.PublicHub() { + err = config.Register(&config.Option{ + Name: "Connect From Advertised IPs Only", + Key: cfgOptionBindToAdvertisedKey, + Description: "Only connect from (bind to) the advertised IP addresses.", + OptType: config.OptTypeBool, + ExpertiseLevel: config.ExpertiseLevelExpert, + DefaultValue: cfgOptionBindToAdvertisedDefault, + RequiresRestart: true, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: cfgOptionBindToAdvertisedOrder, + }, + }) + if err != nil { + return err + } + cfgOptionBindToAdvertised = config.GetAsBool(cfgOptionBindToAdvertisedKey, cfgOptionBindToAdvertisedDefault) + } + + // Config options for use. + cfgOptionRoutingAlgorithm = config.Concurrent.GetAsString(profile.CfgOptionRoutingAlgorithmKey, navigator.DefaultRoutingProfileID) + + return nil +} + +var ( + homeHubPolicy endpoints.Endpoints + homeHubPolicyLock sync.Mutex + homeHubPolicyConfigFlag = config.NewValidityFlag() +) + +func getHomeHubPolicy() (endpoints.Endpoints, error) { + homeHubPolicyLock.Lock() + defer homeHubPolicyLock.Unlock() + + // Return cached value if config is still valid. + if homeHubPolicyConfigFlag.IsValid() { + return homeHubPolicy, nil + } + homeHubPolicyConfigFlag.Refresh() + + // Parse new policy. + policy, err := endpoints.ParseEndpoints(cfgOptionHomeHubPolicy()) + if err != nil { + homeHubPolicy = nil + return nil, err + } + + // Save and return the new policy. + homeHubPolicy = policy + return homeHubPolicy, nil +} + +var ( + dnsExitHubPolicy endpoints.Endpoints + dnsExitHubPolicyLock sync.Mutex + dnsExitHubPolicyConfigFlag = config.NewValidityFlag() +) + +// GetDNSExitHubPolicy return the current DNS exit policy. +func GetDNSExitHubPolicy() (endpoints.Endpoints, error) { + dnsExitHubPolicyLock.Lock() + defer dnsExitHubPolicyLock.Unlock() + + // Return cached value if config is still valid. + if dnsExitHubPolicyConfigFlag.IsValid() { + return dnsExitHubPolicy, nil + } + dnsExitHubPolicyConfigFlag.Refresh() + + // Parse new policy. + policy, err := endpoints.ParseEndpoints(cfgOptionDNSExitHubPolicy()) + if err != nil { + dnsExitHubPolicy = nil + return nil, err + } + + // Save and return the new policy. + dnsExitHubPolicy = policy + return dnsExitHubPolicy, nil +} diff --git a/spn/captain/establish.go b/spn/captain/establish.go new file mode 100644 index 00000000..479098a5 --- /dev/null +++ b/spn/captain/establish.go @@ -0,0 +1,105 @@ +package captain + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" + "github.com/safing/portmaster/spn/terminal" +) + +// EstablishCrane establishes a crane to another Hub. +func EstablishCrane(callerCtx context.Context, dst *hub.Hub) (*docks.Crane, error) { + if conf.PublicHub() && dst.ID == publicIdentity.ID { + return nil, errors.New("connecting to self") + } + if docks.GetAssignedCrane(dst.ID) != nil { + return nil, fmt.Errorf("route to %s already exists", dst.ID) + } + + ship, err := ships.Launch(callerCtx, dst, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to launch ship: %w", err) + } + + // On pure clients, mark all ships as public in order to show unmasked data in logs. + if conf.Client() && !conf.PublicHub() { + ship.MarkPublic() + } + + crane, err := docks.NewCrane(ship, dst, publicIdentity) + if err != nil { + return nil, fmt.Errorf("failed to create crane: %w", err) + } + + err = crane.Start(callerCtx) + if err != nil { + return nil, fmt.Errorf("failed to start crane: %w", err) + } + + // Start gossip op for live map updates. + _, tErr := NewGossipOp(crane.Controller) + if tErr != nil { + crane.Stop(tErr) + return nil, fmt.Errorf("failed to start gossip op: %w", tErr) + } + + return crane, nil +} + +// EstablishPublicLane establishes a crane to another Hub and publishes it. +func EstablishPublicLane(ctx context.Context, dst *hub.Hub) (*docks.Crane, *terminal.Error) { + // Create new context with timeout. + // The maximum timeout is a worst case safeguard. + // Keep in mind that multiple IPs and protocols may be tried in all configurations. + // Some servers will be (possibly on purpose) hard to reach. + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + // Connect to destination and establish communication. + crane, err := EstablishCrane(ctx, dst) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to establish crane: %w", err) + } + + // Publish as Lane. + publishOp, tErr := NewPublishOp(crane.Controller, publicIdentity) + if tErr != nil { + return nil, terminal.ErrInternalError.With("failed to publish: %w", err) + } + + // Wait for publishing to complete. + select { + case tErr := <-publishOp.Result(): + if !tErr.Is(terminal.ErrExplicitAck) { + // Stop crane again, because we failed to publish it. + defer crane.Stop(nil) + return nil, terminal.ErrInternalError.With("failed to publish lane: %w", tErr) + } + + case <-crane.Controller.Ctx().Done(): + defer crane.Stop(nil) + return nil, terminal.ErrStopping + + case <-ctx.Done(): + defer crane.Stop(nil) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, terminal.ErrTimeout + } + return nil, terminal.ErrCanceled + } + + // Query all gossip msgs. + _, tErr = NewGossipQueryOp(crane.Controller) + if tErr != nil { + log.Warningf("spn/captain: failed to start initial gossip query: %s", tErr) + } + + return crane, nil +} diff --git a/spn/captain/exceptions.go b/spn/captain/exceptions.go new file mode 100644 index 00000000..bde30950 --- /dev/null +++ b/spn/captain/exceptions.go @@ -0,0 +1,28 @@ +package captain + +import ( + "net" + "sync" +) + +var ( + exceptionLock sync.Mutex + exceptIPv4 net.IP + exceptIPv6 net.IP +) + +func setExceptions(ipv4, ipv6 net.IP) { + exceptionLock.Lock() + defer exceptionLock.Unlock() + + exceptIPv4 = ipv4 + exceptIPv6 = ipv6 +} + +// IsExcepted checks if the given IP is currently excepted from the SPN. +func IsExcepted(ip net.IP) bool { + exceptionLock.Lock() + defer exceptionLock.Unlock() + + return ip.Equal(exceptIPv4) || ip.Equal(exceptIPv6) +} diff --git a/spn/captain/gossip.go b/spn/captain/gossip.go new file mode 100644 index 00000000..3279367a --- /dev/null +++ b/spn/captain/gossip.go @@ -0,0 +1,38 @@ +package captain + +import ( + "sync" +) + +var ( + gossipOps = make(map[string]*GossipOp) + gossipOpsLock sync.RWMutex +) + +func registerGossipOp(craneID string, op *GossipOp) { + gossipOpsLock.Lock() + defer gossipOpsLock.Unlock() + + gossipOps[craneID] = op +} + +func deleteGossipOp(craneID string) { + gossipOpsLock.Lock() + defer gossipOpsLock.Unlock() + + delete(gossipOps, craneID) +} + +func gossipRelayMsg(receivedFrom string, msgType GossipMsgType, data []byte) { + gossipOpsLock.RLock() + defer gossipOpsLock.RUnlock() + + for craneID, gossipOp := range gossipOps { + // Don't return same msg back to sender. + if craneID == receivedFrom { + continue + } + + gossipOp.sendMsg(msgType, data) + } +} diff --git a/spn/captain/hooks.go b/spn/captain/hooks.go new file mode 100644 index 00000000..6a60f7ea --- /dev/null +++ b/spn/captain/hooks.go @@ -0,0 +1,47 @@ +package captain + +import ( + "time" + + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/docks" +) + +func startDockHooks() { + docks.RegisterCraneUpdateHook(handleCraneUpdate) +} + +func stopDockHooks() { + docks.ResetCraneUpdateHook() +} + +func handleCraneUpdate(crane *docks.Crane) { + if crane == nil { + return + } + + if conf.Client() && crane.Controller != nil && crane.Controller.Abandoning.IsSet() { + // Check connection to home hub. + triggerClientHealthCheck() + } + + if conf.PublicHub() && crane.Public() { + // Update Hub status. + updateConnectionStatus() + } +} + +func updateConnectionStatus() { + // Delay updating status for a better chance to combine multiple changes. + statusUpdateTask.Schedule(time.Now().Add(maintainStatusUpdateDelay)) + + // Check if we lost all connections and trigger a pending restart if we did. + for _, crane := range docks.GetAllAssignedCranes() { + if crane.Public() && !crane.Stopped() { + // There is at least one public and active crane, so don't restart now. + return + } + } + updates.TriggerRestartIfPending() +} diff --git a/spn/captain/intel.go b/spn/captain/intel.go new file mode 100644 index 00000000..fe743c1b --- /dev/null +++ b/spn/captain/intel.go @@ -0,0 +1,108 @@ +package captain + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/updater" + "github.com/safing/portmaster/service/updates" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/ships" +) + +var ( + intelResource *updater.File + intelResourcePath = "intel/spn/main-intel.yaml" + intelResourceMapName = "main" + intelResourceUpdateLock sync.Mutex +) + +func registerIntelUpdateHook() error { + if err := module.RegisterEventHook( + updates.ModuleName, + updates.ResourceUpdateEvent, + "update SPN intel", + updateSPNIntel, + ); err != nil { + return err + } + + if err := module.RegisterEventHook( + "config", + config.ChangeEvent, + "update SPN intel", + updateSPNIntel, + ); err != nil { + return err + } + + return nil +} + +func updateSPNIntel(ctx context.Context, _ interface{}) (err error) { + intelResourceUpdateLock.Lock() + defer intelResourceUpdateLock.Unlock() + + // Only update SPN intel when using the matching map. + if conf.MainMapName != intelResourceMapName { + return fmt.Errorf("intel resource not for map %q", conf.MainMapName) + } + + // Check if there is something to do. + if intelResource != nil && !intelResource.UpgradeAvailable() { + return nil + } + + // Get intel file and load it from disk. + intelResource, err = updates.GetFile(intelResourcePath) + if err != nil { + return fmt.Errorf("failed to get SPN intel update: %w", err) + } + intelData, err := os.ReadFile(intelResource.Path()) + if err != nil { + return fmt.Errorf("failed to load SPN intel update: %w", err) + } + + // Parse and apply intel data. + intel, err := hub.ParseIntel(intelData) + if err != nil { + return fmt.Errorf("failed to parse SPN intel update: %w", err) + } + + setVirtualNetworkConfig(intel.VirtualNetworks) + return navigator.Main.UpdateIntel(intel, cfgOptionTrustNodeNodes()) +} + +func resetSPNIntel() { + intelResourceUpdateLock.Lock() + defer intelResourceUpdateLock.Unlock() + + intelResource = nil +} + +func setVirtualNetworkConfig(configs []*hub.VirtualNetworkConfig) { + // Do nothing if not public Hub. + if !conf.PublicHub() { + return + } + // Reset if there are no virtual networks configured. + if len(configs) == 0 { + ships.SetVirtualNetworkConfig(nil) + } + + // Check if we are in a virtual network. + for _, config := range configs { + if _, ok := config.Mapping[publicIdentity.Hub.ID]; ok { + ships.SetVirtualNetworkConfig(config) + return + } + } + + // If not, reset - we might have been in one before. + ships.SetVirtualNetworkConfig(nil) +} diff --git a/spn/captain/module.go b/spn/captain/module.go new file mode 100644 index 00000000..356eb199 --- /dev/null +++ b/spn/captain/module.go @@ -0,0 +1,219 @@ +package captain + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/config" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/modules/subsystems" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/crew" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/patrol" + "github.com/safing/portmaster/spn/ships" + _ "github.com/safing/portmaster/spn/sluice" +) + +const controlledFailureExitCode = 24 + +var module *modules.Module + +// SPNConnectedEvent is the name of the event that is fired when the SPN has connected and is ready. +const SPNConnectedEvent = "spn connect" + +func init() { + module = modules.Register("captain", prep, start, stop, "base", "terminal", "cabin", "ships", "docks", "crew", "navigator", "sluice", "patrol", "netenv") + module.RegisterEvent(SPNConnectedEvent, false) + subsystems.Register( + "spn", + "SPN", + "Safing Privacy Network", + module, + "config:spn/", + &config.Option{ + Name: "SPN Module", + Key: CfgOptionEnableSPNKey, + Description: "Start the Safing Privacy Network module. If turned off, the SPN is fully disabled on this device.", + OptType: config.OptTypeBool, + DefaultValue: false, + Annotations: config.Annotations{ + config.DisplayOrderAnnotation: cfgOptionEnableSPNOrder, + config.CategoryAnnotation: "General", + }, + }, + ) +} + +func prep() error { + // Check if we can parse the bootstrap hub flag. + if err := prepBootstrapHubFlag(); err != nil { + return err + } + + // Register SPN status provider. + if err := registerSPNStatusProvider(); err != nil { + return err + } + + // Register API endpoints. + if err := registerAPIEndpoints(); err != nil { + return err + } + + if conf.PublicHub() { + // Register API authenticator. + if err := api.SetAuthenticator(apiAuthenticator); err != nil { + return err + } + + if err := module.RegisterEventHook( + "patrol", + patrol.ChangeSignalEventName, + "trigger hub status maintenance", + func(_ context.Context, _ any) error { + TriggerHubStatusMaintenance() + return nil + }, + ); err != nil { + return err + } + } + + return prepConfig() +} + +func start() error { + maskingBytes, err := rng.Bytes(16) + if err != nil { + return fmt.Errorf("failed to get random bytes for masking: %w", err) + } + ships.EnableMasking(maskingBytes) + + // Initialize intel. + if err := registerIntelUpdateHook(); err != nil { + return err + } + if err := updateSPNIntel(module.Ctx, nil); err != nil { + log.Errorf("spn/captain: failed to update SPN intel: %s", err) + } + + // Initialize identity and piers. + if conf.PublicHub() { + // Load identity. + if err := loadPublicIdentity(); err != nil { + // We cannot recover from this, set controlled failure (do not retry). + modules.SetExitStatusCode(controlledFailureExitCode) + + return err + } + + // Check if any networks are configured. + if !conf.HubHasIPv4() && !conf.HubHasIPv6() { + // We cannot recover from this, set controlled failure (do not retry). + modules.SetExitStatusCode(controlledFailureExitCode) + + return errors.New("no IP addresses for Hub configured (or detected)") + } + + // Start management of identity and piers. + if err := prepPublicIdentityMgmt(); err != nil { + return err + } + // Set ID to display on http info page. + ships.DisplayHubID = publicIdentity.ID + // Start listeners. + if err := startPiers(); err != nil { + return err + } + + // Enable connect operation. + crew.EnableConnecting(publicIdentity.Hub) + } + + // Subscribe to updates of cranes. + startDockHooks() + + // bootstrapping + if err := processBootstrapHubFlag(); err != nil { + return err + } + if err := processBootstrapFileFlag(); err != nil { + return err + } + + // network optimizer + if conf.PublicHub() { + module.NewTask("optimize network", optimizeNetwork). + Repeat(1 * time.Minute). + Schedule(time.Now().Add(15 * time.Second)) + } + + // client + home hub manager + if conf.Client() { + module.StartServiceWorker("client manager", 0, clientManager) + + // Reset failing hubs when the network changes while not connected. + if err := module.RegisterEventHook( + "netenv", + "network changed", + "reset failing hubs", + func(_ context.Context, _ interface{}) error { + if ready.IsNotSet() { + navigator.Main.ResetFailingStates(module.Ctx) + } + return nil + }, + ); err != nil { + return err + } + } + + return nil +} + +func stop() error { + // Reset intel resource so that it is loaded again when starting. + resetSPNIntel() + + // Unregister crane update hook. + stopDockHooks() + + // Send shutdown status message. + if conf.PublicHub() { + publishShutdownStatus() + stopPiers() + } + + return nil +} + +// apiAuthenticator grants User permissions for local API requests. +func apiAuthenticator(r *http.Request, s *http.Server) (*api.AuthToken, error) { + // Get remote IP. + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil, fmt.Errorf("failed to split host/port: %w", err) + } + remoteIP := net.ParseIP(host) + if remoteIP == nil { + return nil, fmt.Errorf("failed to parse remote address %s", host) + } + + if !netutils.GetIPScope(remoteIP).IsLocalhost() { + return nil, api.ErrAPIAccessDeniedMessage + } + + return &api.AuthToken{ + Read: api.PermitUser, + Write: api.PermitUser, + }, nil +} diff --git a/spn/captain/navigation.go b/spn/captain/navigation.go new file mode 100644 index 00000000..f080e0bc --- /dev/null +++ b/spn/captain/navigation.go @@ -0,0 +1,306 @@ +package captain + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/terminal" +) + +const stopCraneAfterBeingUnsuggestedFor = 6 * time.Hour + +var ( + // ErrAllHomeHubsExcluded is returned when all available home hubs were excluded. + ErrAllHomeHubsExcluded = errors.New("all home hubs are excluded") + + // ErrReInitSPNSuggested is returned when no home hub can be found, even without rules. + ErrReInitSPNSuggested = errors.New("SPN re-init suggested") +) + +func establishHomeHub(ctx context.Context) error { + // Get own IP. + locations, ok := netenv.GetInternetLocation() + if !ok || len(locations.All) == 0 { + return errors.New("failed to locate own device") + } + log.Debugf( + "spn/captain: looking for new home hub near %s and %s", + locations.BestV4(), + locations.BestV6(), + ) + + // Get own entity. + // Checking the entity against the entry policies is somewhat hit and miss + // anyway, as the device location is an approximation. + var myEntity *intel.Entity + if dl := locations.BestV4(); dl != nil && dl.IP != nil { + myEntity = (&intel.Entity{IP: dl.IP}).Init(0) + myEntity.FetchData(ctx) + } else if dl := locations.BestV6(); dl != nil && dl.IP != nil { + myEntity = (&intel.Entity{IP: dl.IP}).Init(0) + myEntity.FetchData(ctx) + } + + // Get home hub policy for selecting the home hub. + homePolicy, err := getHomeHubPolicy() + if err != nil { + return err + } + + // Build navigation options for searching for a home hub. + opts := &navigator.Options{ + Home: &navigator.HomeHubOptions{ + HubPolicies: []endpoints.Endpoints{homePolicy}, + CheckHubPolicyWith: myEntity, + }, + } + + // Add requirement to only use Safing nodes when not using community nodes. + if !cfgOptionUseCommunityNodes() { + opts.Home.RequireVerifiedOwners = NonCommunityVerifiedOwners + } + + // Require a trusted home node when the routing profile requires less than two hops. + routingProfile := navigator.GetRoutingProfile(cfgOptionRoutingAlgorithm()) + if routingProfile.MinHops < 2 { + opts.Home.Regard = opts.Home.Regard.Add(navigator.StateTrusted) + } + + // Find nearby hubs. +findCandidates: + candidates, err := navigator.Main.FindNearestHubs( + locations.BestV4().LocationOrNil(), + locations.BestV6().LocationOrNil(), + opts, navigator.HomeHub, + ) + if err != nil { + switch { + case errors.Is(err, navigator.ErrEmptyMap): + // bootstrap to the network! + err := bootstrapWithUpdates() + if err != nil { + return err + } + goto findCandidates + + case errors.Is(err, navigator.ErrAllPinsDisregarded): + if len(homePolicy) > 0 { + return ErrAllHomeHubsExcluded + } + return ErrReInitSPNSuggested + + default: + return fmt.Errorf("failed to find nearby hubs: %w", err) + } + } + + // Try connecting to a hub. + var tries int + var candidate *hub.Hub + for tries, candidate = range candidates { + err = connectToHomeHub(ctx, candidate) + if err != nil { + // Check if context is canceled. + if ctx.Err() != nil { + return ctx.Err() + } + // Check if the SPN protocol is stopping again. + if errors.Is(err, terminal.ErrStopping) { + return err + } + log.Warningf("spn/captain: failed to connect to %s as new home: %s", candidate, err) + } else { + log.Infof("spn/captain: established connection to %s as new home with %d failed tries", candidate, tries) + return nil + } + } + if err != nil { + return fmt.Errorf("failed to connect to a new home hub - tried %d hubs: %w", tries+1, err) + } + return errors.New("no home hub candidates available") +} + +func connectToHomeHub(ctx context.Context, dst *hub.Hub) error { + // Create new context with timeout. + // The maximum timeout is a worst case safeguard. + // Keep in mind that multiple IPs and protocols may be tried in all configurations. + // Some servers will be (possibly on purpose) hard to reach. + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + // Set and clean up exceptions. + setExceptions(dst.Info.IPv4, dst.Info.IPv6) + defer setExceptions(nil, nil) + + // Connect to hub. + crane, err := EstablishCrane(ctx, dst) + if err != nil { + return err + } + + // Cleanup connection in case of failure. + var success bool + defer func() { + if !success { + crane.Stop(nil) + } + }() + + // Query all gossip msgs on first connection. + gossipQuery, tErr := NewGossipQueryOp(crane.Controller) + if tErr != nil { + log.Warningf("spn/captain: failed to start initial gossip query: %s", tErr) + } + // Wait for gossip query to complete. + select { + case <-gossipQuery.ctx.Done(): + case <-ctx.Done(): + return context.Canceled + } + + // Create communication terminal. + homeTerminal, initData, tErr := docks.NewLocalCraneTerminal(crane, nil, terminal.DefaultHomeHubTerminalOpts()) + if tErr != nil { + return tErr.Wrap("failed to create home terminal") + } + tErr = crane.EstablishNewTerminal(homeTerminal, initData) + if tErr != nil { + return tErr.Wrap("failed to connect home terminal") + } + + if !DisableAccount { + // Authenticate to home hub. + authOp, tErr := access.AuthorizeToTerminal(homeTerminal) + if tErr != nil { + return tErr.Wrap("failed to authorize") + } + select { + case tErr := <-authOp.Result: + if !tErr.Is(terminal.ErrExplicitAck) { + return tErr.Wrap("failed to authenticate to") + } + case <-time.After(3 * time.Second): + return terminal.ErrTimeout.With("waiting for auth to complete") + case <-ctx.Done(): + return terminal.ErrStopping + } + } + + // Set new home on map. + ok := navigator.Main.SetHome(dst.ID, homeTerminal) + if !ok { + return errors.New("failed to set home hub on map") + } + + // Assign crane to home hub in order to query it later. + docks.AssignCrane(crane.ConnectedHub.ID, crane) + + success = true + return nil +} + +func optimizeNetwork(ctx context.Context, task *modules.Task) error { + if publicIdentity == nil { + return nil + } + +optimize: + result, err := navigator.Main.Optimize(nil) + if err != nil { + if errors.Is(err, navigator.ErrEmptyMap) { + // bootstrap to the network! + err := bootstrapWithUpdates() + if err != nil { + return err + } + goto optimize + } + + return err + } + + // Create any new connections. + var createdConnections int + var attemptedConnections int + for _, connectTo := range result.SuggestedConnections { + // Skip duplicates. + if connectTo.Duplicate { + continue + } + + // Check if connection already exists. + crane := docks.GetAssignedCrane(connectTo.Hub.ID) + if crane != nil { + // Update last suggested timestamp. + crane.NetState.UpdateLastSuggestedAt() + // Continue crane if stopping. + if crane.AbortStopping() { + log.Infof("spn/captain: optimization aborted retiring of %s, removed stopping mark", crane) + crane.NotifyUpdate() + } + + // Create new connections if we have connects left. + } else if createdConnections < result.MaxConnect { + attemptedConnections++ + + crane, tErr := EstablishPublicLane(ctx, connectTo.Hub) + if !tErr.IsOK() { + log.Warningf("spn/captain: failed to establish lane to %s: %s", connectTo.Hub, tErr) + } else { + createdConnections++ + crane.NetState.UpdateLastSuggestedAt() + + log.Infof("spn/captain: established lane to %s", connectTo.Hub) + } + } + } + + // Log optimization result. + if attemptedConnections > 0 { + log.Infof( + "spn/captain: created %d/%d new connections for %s optimization", + createdConnections, + attemptedConnections, + result.Purpose) + } else { + log.Infof( + "spn/captain: checked %d connections for %s optimization", + len(result.SuggestedConnections), + result.Purpose, + ) + } + + // Retire cranes if unsuggested for a while. + if result.StopOthers { + for _, crane := range docks.GetAllAssignedCranes() { + switch { + case crane.Stopped(): + // Crane already stopped. + case crane.IsStopping(): + // Crane is stopping, forcibly stop if mine and suggested. + if crane.IsMine() && crane.NetState.StopSuggested() { + crane.Stop(nil) + } + case crane.IsMine() && crane.NetState.StoppingSuggested(): + // Mark as stopping if mine and suggested. + crane.MarkStopping() + case crane.NetState.RequestStoppingSuggested(stopCraneAfterBeingUnsuggestedFor): + // Mark as stopping requested. + crane.MarkStoppingRequested() + } + } + } + + return nil +} diff --git a/spn/captain/op_gossip.go b/spn/captain/op_gossip.go new file mode 100644 index 00000000..e5fb4377 --- /dev/null +++ b/spn/captain/op_gossip.go @@ -0,0 +1,156 @@ +package captain + +import ( + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/terminal" +) + +// GossipOpType is the type ID of the gossip operation. +const GossipOpType string = "gossip" + +// GossipMsgType is the gossip message type. +type GossipMsgType uint8 + +// Gossip Message Types. +const ( + GossipHubAnnouncementMsg GossipMsgType = 1 + GossipHubStatusMsg GossipMsgType = 2 +) + +func (msgType GossipMsgType) String() string { + switch msgType { + case GossipHubAnnouncementMsg: + return "hub announcement" + case GossipHubStatusMsg: + return "hub status" + default: + return "unknown gossip msg" + } +} + +// GossipOp is used to gossip Hub messages. +type GossipOp struct { + terminal.OperationBase + + craneID string +} + +// Type returns the type ID. +func (op *GossipOp) Type() string { + return GossipOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: GossipOpType, + Requires: terminal.IsCraneController, + Start: runGossipOp, + }) +} + +// NewGossipOp start a new gossip operation. +func NewGossipOp(controller *docks.CraneControllerTerminal) (*GossipOp, *terminal.Error) { + // Create and init. + op := &GossipOp{ + craneID: controller.Crane.ID, + } + err := controller.StartOperation(op, nil, 1*time.Minute) + if err != nil { + return nil, err + } + op.InitOperationBase(controller, op.ID()) + + // Register and return. + registerGossipOp(controller.Crane.ID, op) + return op, nil +} + +func runGossipOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Check if we are run by a controller. + controller, ok := t.(*docks.CraneControllerTerminal) + if !ok { + return nil, terminal.ErrIncorrectUsage.With("gossip op may only be started by a crane controller terminal, but was started by %T", t) + } + + // Create, init, register and return. + op := &GossipOp{ + craneID: controller.Crane.ID, + } + op.InitOperationBase(t, opID) + registerGossipOp(controller.Crane.ID, op) + return op, nil +} + +func (op *GossipOp) sendMsg(msgType GossipMsgType, data []byte) { + // Create message. + msg := op.NewEmptyMsg() + msg.Data = container.New( + varint.Pack8(uint8(msgType)), + data, + ) + msg.Unit.MakeHighPriority() + + // Send. + err := op.Send(msg, 1*time.Second) + if err != nil { + log.Debugf("spn/captain: failed to forward %s via %s: %s", msgType, op.craneID, err) + } +} + +// Deliver delivers a message to the operation. +func (op *GossipOp) Deliver(msg *terminal.Msg) *terminal.Error { + defer msg.Finish() + + gossipMsgTypeN, err := msg.Data.GetNextN8() + if err != nil { + return terminal.ErrMalformedData.With("failed to parse gossip message type") + } + gossipMsgType := GossipMsgType(gossipMsgTypeN) + + // Prepare data. + data := msg.Data.CompileData() + var announcementData, statusData []byte + switch gossipMsgType { + case GossipHubAnnouncementMsg: + announcementData = data + case GossipHubStatusMsg: + statusData = data + default: + log.Warningf("spn/captain: received unknown gossip message type from %s: %d", op.craneID, gossipMsgType) + return nil + } + + // Import and verify. + h, forward, tErr := docks.ImportAndVerifyHubInfo(module.Ctx, "", announcementData, statusData, conf.MainMapName, conf.MainMapScope) + if tErr != nil { + if tErr.Is(hub.ErrOldData) { + log.Debugf("spn/captain: ignoring old %s from %s", gossipMsgType, op.craneID) + } else { + log.Warningf("spn/captain: failed to import %s from %s: %s", gossipMsgType, op.craneID, tErr) + } + } else if forward { + // Only log if we received something to save/forward. + log.Infof("spn/captain: received %s for %s", gossipMsgType, h) + } + + // Relay data. + if forward { + gossipRelayMsg(op.craneID, gossipMsgType, data) + } + return nil +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *GossipOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + deleteGossipOp(op.craneID) + return err +} diff --git a/spn/captain/op_gossip_query.go b/spn/captain/op_gossip_query.go new file mode 100644 index 00000000..aaadbc21 --- /dev/null +++ b/spn/captain/op_gossip_query.go @@ -0,0 +1,195 @@ +package captain + +import ( + "context" + "strings" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/terminal" +) + +// GossipQueryOpType is the type ID of the gossip query operation. +const GossipQueryOpType string = "gossip/query" + +// GossipQueryOp is used to query gossip messages. +type GossipQueryOp struct { + terminal.OperationBase + + t terminal.Terminal + client bool + importCnt int + + ctx context.Context + cancelCtx context.CancelFunc +} + +// Type returns the type ID. +func (op *GossipQueryOp) Type() string { + return GossipQueryOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: GossipQueryOpType, + Requires: terminal.IsCraneController, + Start: runGossipQueryOp, + }) +} + +// NewGossipQueryOp starts a new gossip query operation. +func NewGossipQueryOp(t terminal.Terminal) (*GossipQueryOp, *terminal.Error) { + // Create and init. + op := &GossipQueryOp{ + t: t, + client: true, + } + op.ctx, op.cancelCtx = context.WithCancel(t.Ctx()) + err := t.StartOperation(op, nil, 1*time.Minute) + if err != nil { + return nil, err + } + return op, nil +} + +func runGossipQueryOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Create, init, register and return. + op := &GossipQueryOp{t: t} + op.ctx, op.cancelCtx = context.WithCancel(t.Ctx()) + op.InitOperationBase(t, opID) + + module.StartWorker("gossip query handler", op.handler) + + return op, nil +} + +func (op *GossipQueryOp) handler(_ context.Context) error { + tErr := op.sendMsgs(hub.MsgTypeAnnouncement) + if tErr != nil { + op.Stop(op, tErr) + return nil // Clean worker exit. + } + + tErr = op.sendMsgs(hub.MsgTypeStatus) + if tErr != nil { + op.Stop(op, tErr) + return nil // Clean worker exit. + } + + op.Stop(op, nil) + return nil // Clean worker exit. +} + +func (op *GossipQueryOp) sendMsgs(msgType hub.MsgType) *terminal.Error { + it, err := hub.QueryRawGossipMsgs(conf.MainMapName, msgType) + if err != nil { + return terminal.ErrInternalError.With("failed to query: %w", err) + } + defer it.Cancel() + +iterating: + for { + select { + case r := <-it.Next: + // Check if we are done. + if r == nil { + return nil + } + + // Ensure we're handling a hub msg. + hubMsg, err := hub.EnsureHubMsg(r) + if err != nil { + log.Warningf("spn/captain: failed to load hub msg: %s", err) + continue iterating + } + + // Create gossip msg. + var c *container.Container + switch hubMsg.Type { + case hub.MsgTypeAnnouncement: + c = container.New( + varint.Pack8(uint8(GossipHubAnnouncementMsg)), + hubMsg.Data, + ) + case hub.MsgTypeStatus: + c = container.New( + varint.Pack8(uint8(GossipHubStatusMsg)), + hubMsg.Data, + ) + default: + log.Warningf("spn/captain: unknown hub msg for gossip query at %q: %s", hubMsg.Key(), hubMsg.Type) + } + + // Send msg. + if c != nil { + msg := op.NewEmptyMsg() + msg.Unit.MakeHighPriority() + msg.Data = c + tErr := op.Send(msg, 1*time.Second) + if tErr != nil { + return tErr.Wrap("failed to send msg") + } + } + + case <-op.ctx.Done(): + return terminal.ErrStopping + } + } +} + +// Deliver delivers the message to the operation. +func (op *GossipQueryOp) Deliver(msg *terminal.Msg) *terminal.Error { + defer msg.Finish() + + gossipMsgTypeN, err := msg.Data.GetNextN8() + if err != nil { + return terminal.ErrMalformedData.With("failed to parse gossip message type") + } + gossipMsgType := GossipMsgType(gossipMsgTypeN) + + // Prepare data. + data := msg.Data.CompileData() + var announcementData, statusData []byte + switch gossipMsgType { + case GossipHubAnnouncementMsg: + announcementData = data + case GossipHubStatusMsg: + statusData = data + default: + log.Warningf("spn/captain: received unknown gossip message type from gossip query: %d", gossipMsgType) + return nil + } + + // Import and verify. + h, forward, tErr := docks.ImportAndVerifyHubInfo(module.Ctx, "", announcementData, statusData, conf.MainMapName, conf.MainMapScope) + if tErr != nil { + log.Warningf("spn/captain: failed to import %s from gossip query: %s", gossipMsgType, tErr) + } else { + log.Infof("spn/captain: received %s for %s from gossip query", gossipMsgType, h) + op.importCnt++ + } + + // Relay data. + if forward { + // TODO: Find better way to get craneID. + craneID := strings.SplitN(op.t.FmtID(), "#", 2)[0] + gossipRelayMsg(craneID, gossipMsgType, data) + } + return nil +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *GossipQueryOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + if op.client { + log.Infof("spn/captain: gossip query imported %d entries", op.importCnt) + } + op.cancelCtx() + return err +} diff --git a/spn/captain/op_publish.go b/spn/captain/op_publish.go new file mode 100644 index 00000000..178d1e88 --- /dev/null +++ b/spn/captain/op_publish.go @@ -0,0 +1,183 @@ +package captain + +import ( + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/terminal" +) + +// PublishOpType is the type ID of the publish operation. +const PublishOpType string = "publish" + +// PublishOp is used to publish a connection. +type PublishOp struct { + terminal.OperationBase + controller *docks.CraneControllerTerminal + + identity *cabin.Identity + requestingHub *hub.Hub + verification *cabin.Verification + result chan *terminal.Error +} + +// Type returns the type ID. +func (op *PublishOp) Type() string { + return PublishOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: PublishOpType, + Requires: terminal.IsCraneController, + Start: runPublishOp, + }) +} + +// NewPublishOp start a new publish operation. +func NewPublishOp(controller *docks.CraneControllerTerminal, identity *cabin.Identity) (*PublishOp, *terminal.Error) { + // Create and init. + op := &PublishOp{ + controller: controller, + identity: identity, + result: make(chan *terminal.Error, 1), + } + msg := container.New() + + // Add Hub Announcement. + announcementData, err := identity.ExportAnnouncement() + if err != nil { + return nil, terminal.ErrInternalError.With("failed to export announcement: %w", err) + } + msg.AppendAsBlock(announcementData) + + // Add Hub Status. + statusData, err := identity.ExportStatus() + if err != nil { + return nil, terminal.ErrInternalError.With("failed to export status: %w", err) + } + msg.AppendAsBlock(statusData) + + tErr := controller.StartOperation(op, msg, 10*time.Second) + if tErr != nil { + return nil, tErr + } + return op, nil +} + +func runPublishOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Check if we are run by a controller. + controller, ok := t.(*docks.CraneControllerTerminal) + if !ok { + return nil, terminal.ErrIncorrectUsage.With("publish op may only be started by a crane controller terminal, but was started by %T", t) + } + + // Parse and import Announcement and Status. + announcementData, err := data.GetNextBlock() + if err != nil { + return nil, terminal.ErrMalformedData.With("failed to get announcement: %w", err) + } + statusData, err := data.GetNextBlock() + if err != nil { + return nil, terminal.ErrMalformedData.With("failed to get status: %w", err) + } + h, forward, tErr := docks.ImportAndVerifyHubInfo(module.Ctx, "", announcementData, statusData, conf.MainMapName, conf.MainMapScope) + if tErr != nil { + return nil, tErr.Wrap("failed to import and verify hub") + } + // Update reference in case it was changed by the import. + controller.Crane.ConnectedHub = h + + // Relay data. + if forward { + gossipRelayMsg(controller.Crane.ID, GossipHubAnnouncementMsg, announcementData) + gossipRelayMsg(controller.Crane.ID, GossipHubStatusMsg, statusData) + } + + // Create verification request. + v, request, err := cabin.CreateVerificationRequest(PublishOpType, "", "") + if err != nil { + return nil, terminal.ErrInternalError.With("failed to create verification request: %w", err) + } + + // Create operation. + op := &PublishOp{ + controller: controller, + requestingHub: h, + verification: v, + result: make(chan *terminal.Error, 1), + } + op.InitOperationBase(controller, opID) + + // Reply with verification request. + tErr = op.Send(op.NewMsg(request), 10*time.Second) + if tErr != nil { + return nil, tErr.Wrap("failed to send verification request") + } + + return op, nil +} + +// Deliver delivers a message to the operation. +func (op *PublishOp) Deliver(msg *terminal.Msg) *terminal.Error { + defer msg.Finish() + + if op.identity != nil { + // Client + + // Sign the received verification request. + response, err := op.identity.SignVerificationRequest(msg.Data.CompileData(), PublishOpType, "", "") + if err != nil { + return terminal.ErrPermissionDenied.With("signing verification request failed: %w", err) + } + + return op.Send(op.NewMsg(response), 10*time.Second) + } else if op.requestingHub != nil { + // Server + + // Verify the signed request. + err := op.verification.Verify(msg.Data.CompileData(), op.requestingHub) + if err != nil { + return terminal.ErrPermissionDenied.With("checking verification request failed: %w", err) + } + return terminal.ErrExplicitAck + } + + return terminal.ErrInternalError.With("invalid operation state") +} + +// Result returns the result (end error) of the operation. +func (op *PublishOp) Result() <-chan *terminal.Error { + return op.result +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *PublishOp) HandleStop(tErr *terminal.Error) (errorToSend *terminal.Error) { + if tErr.Is(terminal.ErrExplicitAck) { + // TODO: Check for concurrenct access. + if op.controller.Crane.ConnectedHub == nil { + op.controller.Crane.ConnectedHub = op.requestingHub + } + + // Publish crane, abort if it fails. + err := op.controller.Crane.Publish() + if err != nil { + tErr = terminal.ErrInternalError.With("failed to publish crane: %w", err) + op.controller.Crane.Stop(tErr) + } else { + op.controller.Crane.NotifyUpdate() + } + } + + select { + case op.result <- tErr: + default: + } + return tErr +} diff --git a/spn/captain/piers.go b/spn/captain/piers.go new file mode 100644 index 00000000..b0c994bf --- /dev/null +++ b/spn/captain/piers.go @@ -0,0 +1,131 @@ +package captain + +import ( + "context" + "errors" + "fmt" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" +) + +var ( + dockingRequests = make(chan ships.Ship, 100) + piers []ships.Pier +) + +func startPiers() error { + // Get and check transports. + transports := publicIdentity.Hub.Info.Transports + if len(transports) == 0 { + return errors.New("no transports defined") + } + + piers = make([]ships.Pier, 0, len(transports)) + for _, t := range transports { + // Parse transport. + transport, err := hub.ParseTransport(t) + if err != nil { + return fmt.Errorf("cannot build pier for invalid transport %q: %w", t, err) + } + + // Establish pier / listener. + pier, err := ships.EstablishPier(transport, dockingRequests) + if err != nil { + return fmt.Errorf("failed to establish pier for transport %q: %w", t, err) + } + + piers = append(piers, pier) + log.Infof("spn/captain: pier for transport %q built", t) + } + + // Start worker to handle docking requests. + module.StartServiceWorker("docking request handler", 0, dockingRequestHandler) + + return nil +} + +func stopPiers() { + for _, pier := range piers { + pier.Abolish() + } +} + +func dockingRequestHandler(ctx context.Context) error { + // Sink all waiting ships when this worker ends. + // But don't be destructive so the service worker could recover. + defer func() { + for { + select { + case ship := <-dockingRequests: + if ship != nil { + ship.Sink() + } + default: + return + } + } + }() + + for { + select { + case <-ctx.Done(): + return nil + case ship := <-dockingRequests: + // Ignore nil ships. + if ship == nil { + continue + } + + if err := checkDockingPermission(ctx, ship); err != nil { + log.Warningf("spn/captain: denied ship from %s to dock at pier %s: %s", ship.RemoteAddr(), ship.Transport().String(), err) + } else { + handleDockingRequest(ship) + } + } + } +} + +func checkDockingPermission(ctx context.Context, ship ships.Ship) error { + remoteIP, remotePort, err := netutils.IPPortFromAddr(ship.RemoteAddr()) + if err != nil { + return fmt.Errorf("failed to parse remote IP: %w", err) + } + + // Create entity. + entity := (&intel.Entity{ + IP: remoteIP, + Protocol: uint8(netutils.ProtocolFromNetwork(ship.RemoteAddr().Network())), + Port: remotePort, + }).Init(ship.Transport().Port) + entity.FetchData(ctx) + + // Check against policy. + result, reason := publicIdentity.Hub.GetInfo().EntryPolicy().Match(ctx, entity) + if result == endpoints.Denied { + return fmt.Errorf("entry policy violated: %s", reason) + } + + return nil +} + +func handleDockingRequest(ship ships.Ship) { + log.Infof("spn/captain: pemitting %s to dock", ship) + + crane, err := docks.NewCrane(ship, nil, publicIdentity) + if err != nil { + log.Warningf("spn/captain: failed to commission crane for %s: %s", ship, err) + return + } + + module.StartWorker("start crane", func(ctx context.Context) error { + _ = crane.Start(ctx) + // Crane handles errors internally. + return nil + }) +} diff --git a/spn/captain/public.go b/spn/captain/public.go new file mode 100644 index 00000000..04710d9f --- /dev/null +++ b/spn/captain/public.go @@ -0,0 +1,247 @@ +package captain + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/log" + "github.com/safing/portbase/metrics" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/patrol" +) + +const ( + maintainStatusInterval = 15 * time.Minute + maintainStatusUpdateDelay = 5 * time.Second +) + +var ( + publicIdentity *cabin.Identity + publicIdentityKey = "core:spn/public/identity" + + publicIdentityUpdateTask *modules.Task + statusUpdateTask *modules.Task +) + +func loadPublicIdentity() (err error) { + var changed bool + + publicIdentity, changed, err = cabin.LoadIdentity(publicIdentityKey) + switch { + case err == nil: + // load was successful + log.Infof("spn/captain: loaded public hub identity %s", publicIdentity.Hub.ID) + case errors.Is(err, database.ErrNotFound): + // does not exist, create new + publicIdentity, err = cabin.CreateIdentity(module.Ctx, conf.MainMapName) + if err != nil { + return fmt.Errorf("failed to create new identity: %w", err) + } + publicIdentity.SetKey(publicIdentityKey) + changed = true + + log.Infof("spn/captain: created new public hub identity %s", publicIdentity.ID) + default: + // loading error, abort + return fmt.Errorf("failed to load public identity: %w", err) + } + + // Save to database if the identity changed. + if changed { + err = publicIdentity.Save() + if err != nil { + return fmt.Errorf("failed to save new/updated identity to database: %w", err) + } + } + + // Set available networks. + conf.SetHubNetworks( + publicIdentity.Hub.Info.IPv4 != nil, + publicIdentity.Hub.Info.IPv6 != nil, + ) + if cfgOptionBindToAdvertised() { + conf.SetBindAddr(publicIdentity.Hub.Info.IPv4, publicIdentity.Hub.Info.IPv6) + } + + // Set Home Hub before updating the hub on the map, as this would trigger a + // recalculation without a Home Hub. + ok := navigator.Main.SetHome(publicIdentity.ID, nil) + // Always update the navigator in any case in order to sync the reference to + // the active struct of the identity. + navigator.Main.UpdateHub(publicIdentity.Hub) + // Setting the Home Hub will have failed if the identidy was only just + // created - try again if it failed. + if !ok { + ok = navigator.Main.SetHome(publicIdentity.ID, nil) + if !ok { + return errors.New("failed to set self as home hub") + } + } + + return nil +} + +func prepPublicIdentityMgmt() error { + publicIdentityUpdateTask = module.NewTask( + "maintain public identity", + maintainPublicIdentity, + ) + + statusUpdateTask = module.NewTask( + "maintain public status", + maintainPublicStatus, + ).Repeat(maintainStatusInterval) + + return module.RegisterEventHook( + "config", + "config change", + "update public identity from config", + func(_ context.Context, _ interface{}) error { + // trigger update in 5 minutes + publicIdentityUpdateTask.Schedule(time.Now().Add(5 * time.Minute)) + return nil + }, + ) +} + +// TriggerHubStatusMaintenance queues the Hub status update task to be executed. +func TriggerHubStatusMaintenance() { + if statusUpdateTask != nil { + statusUpdateTask.Queue() + } +} + +func maintainPublicIdentity(ctx context.Context, task *modules.Task) error { + changed, err := publicIdentity.MaintainAnnouncement(nil, false) + if err != nil { + return fmt.Errorf("failed to maintain announcement: %w", err) + } + + if !changed { + return nil + } + + // Update on map. + navigator.Main.UpdateHub(publicIdentity.Hub) + log.Debug("spn/captain: updated own hub on map after announcement change") + + // export announcement + announcementData, err := publicIdentity.ExportAnnouncement() + if err != nil { + return fmt.Errorf("failed to export announcement: %w", err) + } + + // forward to other connected Hubs + gossipRelayMsg("", GossipHubAnnouncementMsg, announcementData) + + return nil +} + +func maintainPublicStatus(ctx context.Context, task *modules.Task) error { + // Get current lanes. + cranes := docks.GetAllAssignedCranes() + lanes := make([]*hub.Lane, 0, len(cranes)) + for _, crane := range cranes { + // Ignore private, stopped or stopping cranes. + if !crane.Public() || crane.Stopped() || crane.IsStopping() { + continue + } + + // Get measurements. + measurements := crane.ConnectedHub.GetMeasurements() + latency, _ := measurements.GetLatency() + capacity, _ := measurements.GetCapacity() + + // Add crane lane. + lanes = append(lanes, &hub.Lane{ + ID: crane.ConnectedHub.ID, + Latency: latency, + Capacity: capacity, + }) + } + // Sort Lanes for comparing. + hub.SortLanes(lanes) + + // Get system load and convert to fixed steps. + var load int + loadAvg, ok := metrics.LoadAvg15() + switch { + case !ok: + load = -1 + case loadAvg >= 1: + load = 100 + case loadAvg >= 0.95: + load = 95 + case loadAvg >= 0.8: + load = 80 + default: + load = 0 + } + if loadAvg >= 0.8 { + log.Warningf("spn/captain: publishing 15m system load average of %.2f as %d", loadAvg, load) + } + + // Set flags. + var flags []string + if !patrol.HTTPSConnectivityConfirmed() { + flags = append(flags, hub.FlagNetError) + } + // Sort Lanes for comparing. + sort.Strings(flags) + + // Run maintenance with the new data. + changed, err := publicIdentity.MaintainStatus(lanes, &load, flags, false) + if err != nil { + return fmt.Errorf("failed to maintain status: %w", err) + } + + if !changed { + return nil + } + + // Update on map. + navigator.Main.UpdateHub(publicIdentity.Hub) + log.Debug("spn/captain: updated own hub on map after status change") + + // export status + statusData, err := publicIdentity.ExportStatus() + if err != nil { + return fmt.Errorf("failed to export status: %w", err) + } + + // forward to other connected Hubs + gossipRelayMsg("", GossipHubStatusMsg, statusData) + + log.Infof( + "spn/captain: updated status with load %d and current lanes: %v", + publicIdentity.Hub.Status.Load, + publicIdentity.Hub.Status.Lanes, + ) + return nil +} + +func publishShutdownStatus() { + // Create offline status. + offlineStatusData, err := publicIdentity.MakeOfflineStatus() + if err != nil { + log.Errorf("spn/captain: failed to create offline status: %s", err) + return + } + + // Forward to other connected Hubs. + gossipRelayMsg("", GossipHubStatusMsg, offlineStatusData) + + // Leave some time for the message to broadcast. + time.Sleep(2 * time.Second) + + log.Infof("spn/captain: broadcasted offline status") +} diff --git a/spn/captain/status.go b/spn/captain/status.go new file mode 100644 index 00000000..99b6632c --- /dev/null +++ b/spn/captain/status.go @@ -0,0 +1,154 @@ +package captain + +import ( + "fmt" + "sort" + "sync" + "time" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/runtime" + "github.com/safing/portbase/utils/debug" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/navigator" +) + +// SPNStatus holds SPN status information. +type SPNStatus struct { + record.Base + sync.Mutex + + Status SPNStatusName + HomeHubID string + HomeHubName string + ConnectedIP string + ConnectedTransport string + ConnectedCountry *geoip.CountryInfo + ConnectedSince *time.Time +} + +// SPNStatusName is a SPN status. +type SPNStatusName string + +// SPN Stati. +const ( + StatusFailed SPNStatusName = "failed" + StatusDisabled SPNStatusName = "disabled" + StatusConnecting SPNStatusName = "connecting" + StatusConnected SPNStatusName = "connected" +) + +var ( + spnStatus = &SPNStatus{ + Status: StatusDisabled, + } + spnStatusPushFunc runtime.PushFunc +) + +func registerSPNStatusProvider() (err error) { + spnStatus.SetKey("runtime:spn/status") + spnStatus.UpdateMeta() + spnStatusPushFunc, err = runtime.Register("spn/status", runtime.ProvideRecord(spnStatus)) + return +} + +func resetSPNStatus(statusName SPNStatusName, overrideEvenIfConnected bool) { + // Lock for updating values. + spnStatus.Lock() + defer spnStatus.Unlock() + + // Ignore when connected and not overriding + if !overrideEvenIfConnected && spnStatus.Status == StatusConnected { + return + } + + // Reset status. + spnStatus.Status = statusName + spnStatus.HomeHubID = "" + spnStatus.HomeHubName = "" + spnStatus.ConnectedIP = "" + spnStatus.ConnectedTransport = "" + spnStatus.ConnectedCountry = nil + spnStatus.ConnectedSince = nil + + // Push new status. + pushSPNStatusUpdate() +} + +// pushSPNStatusUpdate pushes an update of spnStatus, which must be locked. +func pushSPNStatusUpdate() { + spnStatus.UpdateMeta() + spnStatusPushFunc(spnStatus) +} + +// GetSPNStatus returns the current SPN status. +func GetSPNStatus() *SPNStatus { + spnStatus.Lock() + defer spnStatus.Unlock() + + return &SPNStatus{ + Status: spnStatus.Status, + HomeHubID: spnStatus.HomeHubID, + HomeHubName: spnStatus.HomeHubName, + ConnectedIP: spnStatus.ConnectedIP, + ConnectedTransport: spnStatus.ConnectedTransport, + ConnectedCountry: spnStatus.ConnectedCountry, + ConnectedSince: spnStatus.ConnectedSince, + } +} + +// AddToDebugInfo adds the SPN status to the given debug.Info. +func AddToDebugInfo(di *debug.Info) { + spnStatus.Lock() + defer spnStatus.Unlock() + + // Check if SPN module is enabled. + var moduleStatus string + spnEnabled := config.GetAsBool(CfgOptionEnableSPNKey, false) + if spnEnabled() { + moduleStatus = "enabled" + } else { + moduleStatus = "disabled" + } + + // Collect status data. + lines := make([]string, 0, 20) + lines = append(lines, fmt.Sprintf("HomeHubID: %v", spnStatus.HomeHubID)) + lines = append(lines, fmt.Sprintf("HomeHubName: %v", spnStatus.HomeHubName)) + lines = append(lines, fmt.Sprintf("HomeHubIP: %v", spnStatus.ConnectedIP)) + lines = append(lines, fmt.Sprintf("Transport: %v", spnStatus.ConnectedTransport)) + if spnStatus.ConnectedSince != nil { + lines = append(lines, fmt.Sprintf("Connected: %v ago", time.Since(*spnStatus.ConnectedSince).Round(time.Minute))) + } + lines = append(lines, "---") + lines = append(lines, fmt.Sprintf("Client: %v", conf.Client())) + lines = append(lines, fmt.Sprintf("PublicHub: %v", conf.PublicHub())) + lines = append(lines, fmt.Sprintf("HubHasIPv4: %v", conf.HubHasIPv4())) + lines = append(lines, fmt.Sprintf("HubHasIPv6: %v", conf.HubHasIPv6())) + + // Collect status data of map. + if navigator.Main != nil { + lines = append(lines, "---") + mainMapStats := navigator.Main.Stats() + lines = append(lines, fmt.Sprintf("Map %s:", navigator.Main.Name)) + lines = append(lines, fmt.Sprintf("Active Terminals: %d Hubs", mainMapStats.ActiveTerminals)) + // Collect hub states. + mapStateSummary := make([]string, 0, len(mainMapStats.States)) + for state, cnt := range mainMapStats.States { + if cnt > 0 { + mapStateSummary = append(mapStateSummary, fmt.Sprintf("State %s: %d Hubs", state, cnt)) + } + } + sort.Strings(mapStateSummary) + lines = append(lines, mapStateSummary...) + } + + // Add all data as section. + di.AddSection( + fmt.Sprintf("SPN: %s (module %s)", spnStatus.Status, moduleStatus), + debug.UseCodeSection|debug.AddContentLineBreaks, + lines..., + ) +} diff --git a/spn/conf/map.go b/spn/conf/map.go new file mode 100644 index 00000000..e720be1a --- /dev/null +++ b/spn/conf/map.go @@ -0,0 +1,17 @@ +package conf + +import ( + "flag" + + "github.com/safing/portmaster/spn/hub" +) + +// Primary Map Configuration. +var ( + MainMapName = "main" + MainMapScope = hub.ScopePublic +) + +func init() { + flag.StringVar(&MainMapName, "spn-map", "main", "set main SPN map - use only for testing") +} diff --git a/spn/conf/mode.go b/spn/conf/mode.go new file mode 100644 index 00000000..cc1248bb --- /dev/null +++ b/spn/conf/mode.go @@ -0,0 +1,30 @@ +package conf + +import ( + "github.com/tevino/abool" +) + +var ( + publicHub = abool.New() + client = abool.New() +) + +// PublicHub returns whether this is a public Hub. +func PublicHub() bool { + return publicHub.IsSet() +} + +// EnablePublicHub enables the public hub mode. +func EnablePublicHub(enable bool) { + publicHub.SetTo(enable) +} + +// Client returns whether this is a client. +func Client() bool { + return client.IsSet() +} + +// EnableClient enables the client mode. +func EnableClient(enable bool) { + client.SetTo(enable) +} diff --git a/spn/conf/networks.go b/spn/conf/networks.go new file mode 100644 index 00000000..379395c3 --- /dev/null +++ b/spn/conf/networks.go @@ -0,0 +1,110 @@ +package conf + +import ( + "net" + "sync" + + "github.com/tevino/abool" +) + +var ( + hubHasV4 = abool.New() + hubHasV6 = abool.New() +) + +// SetHubNetworks sets the available IP networks on the Hub. +func SetHubNetworks(v4, v6 bool) { + hubHasV4.SetTo(v4) + hubHasV6.SetTo(v6) +} + +// HubHasIPv4 returns whether the Hub has IPv4 support. +func HubHasIPv4() bool { + return hubHasV4.IsSet() +} + +// HubHasIPv6 returns whether the Hub has IPv6 support. +func HubHasIPv6() bool { + return hubHasV6.IsSet() +} + +var ( + bindIPv4 net.IP + bindIPv6 net.IP + bindIPLock sync.Mutex +) + +// SetBindAddr sets the preferred connect (bind) addresses. +func SetBindAddr(ip4, ip6 net.IP) { + bindIPLock.Lock() + defer bindIPLock.Unlock() + + bindIPv4 = ip4 + bindIPv6 = ip6 +} + +// BindAddrIsSet returns whether any bind address is set. +func BindAddrIsSet() bool { + bindIPLock.Lock() + defer bindIPLock.Unlock() + + return bindIPv4 != nil || bindIPv6 != nil +} + +// GetBindAddr returns an address with the preferred binding address for the +// given dial network. +// The dial network must have a suffix specifying the IP version. +func GetBindAddr(dialNetwork string) net.Addr { + bindIPLock.Lock() + defer bindIPLock.Unlock() + + switch dialNetwork { + case "ip4": + if bindIPv4 != nil { + return &net.IPAddr{IP: bindIPv4} + } + case "ip6": + if bindIPv6 != nil { + return &net.IPAddr{IP: bindIPv6} + } + case "tcp4": + if bindIPv4 != nil { + return &net.TCPAddr{IP: bindIPv4} + } + case "tcp6": + if bindIPv6 != nil { + return &net.TCPAddr{IP: bindIPv6} + } + case "udp4": + if bindIPv4 != nil { + return &net.UDPAddr{IP: bindIPv4} + } + case "udp6": + if bindIPv6 != nil { + return &net.UDPAddr{IP: bindIPv6} + } + } + + return nil +} + +// GetBindIPs returns the preferred binding IPs. +// Returns a slice with a single nil IP if no preferred binding IPs are set. +func GetBindIPs() []net.IP { + bindIPLock.Lock() + defer bindIPLock.Unlock() + + switch { + case bindIPv4 == nil && bindIPv6 == nil: + // Match most common case first. + return []net.IP{nil} + case bindIPv4 != nil && bindIPv6 != nil: + return []net.IP{bindIPv4, bindIPv6} + case bindIPv4 != nil: + return []net.IP{bindIPv4} + case bindIPv6 != nil: + return []net.IP{bindIPv6} + } + + return []net.IP{nil} +} diff --git a/spn/conf/version.go b/spn/conf/version.go new file mode 100644 index 00000000..ec5f3f03 --- /dev/null +++ b/spn/conf/version.go @@ -0,0 +1,9 @@ +package conf + +const ( + // VersionOne is the first protocol version. + VersionOne = 1 + + // CurrentVersion always holds the newest version in production. + CurrentVersion = 1 +) diff --git a/spn/crew/connect.go b/spn/crew/connect.go new file mode 100644 index 00000000..4f376e44 --- /dev/null +++ b/spn/crew/connect.go @@ -0,0 +1,482 @@ +package crew + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/terminal" +) + +// connectLock locks all routing operations to mitigate racy stuff for now. +// TODO: Find a nice way to parallelize route creation. +var connectLock sync.Mutex + +// HandleSluiceRequest handles a sluice request to build a tunnel. +func HandleSluiceRequest(connInfo *network.Connection, conn net.Conn) { + if conn == nil { + log.Debugf("spn/crew: closing tunnel for %s before starting because of shutdown", connInfo) + + // This is called within the connInfo lock. + connInfo.Failed("tunnel entry closed", "") + connInfo.SaveWhenFinished() + return + } + + t := &Tunnel{ + connInfo: connInfo, + conn: conn, + } + module.StartWorker("tunnel handler", t.connectWorker) +} + +// Tunnel represents the local information and endpoint of a data tunnel. +type Tunnel struct { + connInfo *network.Connection + conn net.Conn + + dstPin *navigator.Pin + dstTerminal terminal.Terminal + route *navigator.Route + failedTries int + stickied bool +} + +func (t *Tunnel) connectWorker(ctx context.Context) (err error) { + // Get tracing logger. + ctx, tracer := log.AddTracer(ctx) + defer tracer.Submit() + + // Save start time. + started := time.Now() + + // Check the status of the Home Hub. + home, homeTerminal := navigator.Main.GetHome() + if home == nil || homeTerminal == nil || homeTerminal.IsBeingAbandoned() { + reportConnectError(terminal.ErrUnknownError.With("home terminal is abandoned")) + + t.connInfo.Lock() + defer t.connInfo.Unlock() + t.connInfo.Failed("SPN not ready for tunneling", "") + t.connInfo.Save() + + tracer.Infof("spn/crew: not tunneling %s, as the SPN is not ready", t.connInfo) + return nil + } + + // Create path through the SPN. + err = t.establish(ctx) + if err != nil { + log.Warningf("spn/crew: failed to establish route for %s: %s", t.connInfo, err) + + // TODO: Clean this up. + t.connInfo.Lock() + defer t.connInfo.Unlock() + t.connInfo.Failed("SPN failed to establish route: "+err.Error(), "") + t.connInfo.Save() + + tracer.Warningf("spn/crew: failed to establish route for %s: %s", t.connInfo, err) + return nil + } + + // Connect via established tunnel. + _, tErr := NewConnectOp(t) + if tErr != nil { + tErr = tErr.Wrap("failed to initialize tunnel") + reportConnectError(tErr) + + t.connInfo.Lock() + defer t.connInfo.Unlock() + t.connInfo.Failed("SPN failed to initialize data tunnel (connect op): "+tErr.Error(), "") + t.connInfo.Save() + + // TODO: try with another route? + tracer.Warningf("spn/crew: failed to initialize data tunnel (connect op) for %s: %s", t.connInfo, err) + return tErr + } + + // Report time taken to find, build and check route and send connect request. + connectOpTTCRDurationHistogram.UpdateDuration(started) + + t.connInfo.Lock() + defer t.connInfo.Unlock() + addTunnelContextToConnection(t) + t.connInfo.Save() + + tracer.Infof("spn/crew: connected %s via %s", t.connInfo, t.dstPin.Hub) + return nil +} + +func (t *Tunnel) establish(ctx context.Context) (err error) { + var routes *navigator.Routes + + // Check if the destination sticks to a Hub. + sticksTo := getStickiedHub(t.connInfo) + switch { + case sticksTo == nil: + // Continue. + + case sticksTo.Avoid: + log.Tracer(ctx).Tracef("spn/crew: avoiding %s", sticksTo.Pin.Hub) + + // Avoid this Hub. + // TODO: Remember more than one hub to avoid. + avoidPolicy := []endpoints.Endpoint{ + &endpoints.EndpointDomain{ + OriginalValue: sticksTo.Pin.Hub.ID, + Domain: strings.ToLower(sticksTo.Pin.Hub.ID) + ".", + }, + } + + // Append to policies. + t.connInfo.TunnelOpts.Destination.HubPolicies = append(t.connInfo.TunnelOpts.Destination.HubPolicies, avoidPolicy) + + default: + log.Tracer(ctx).Tracef("spn/crew: using stickied %s", sticksTo.Pin.Hub) + + // Check if the stickied Hub has an active terminal. + dstTerminal := sticksTo.Pin.GetActiveTerminal() + if dstTerminal != nil { + t.dstPin = sticksTo.Pin + t.dstTerminal = dstTerminal + t.route = sticksTo.Route + t.stickied = true + return nil + } + + // If not, attempt to find a route to the stickied hub. + routes, err = navigator.Main.FindRouteToHub( + sticksTo.Pin.Hub.ID, + t.connInfo.TunnelOpts, + ) + if err != nil { + log.Tracer(ctx).Tracef("spn/crew: failed to find route to stickied %s: %s", sticksTo.Pin.Hub, err) + routes = nil + } else { + t.stickied = true + } + } + + // Find possible routes to destination. + if routes == nil { + log.Tracer(ctx).Trace("spn/crew: finding routes...") + routes, err = navigator.Main.FindRoutes( + t.connInfo.Entity.IP, + t.connInfo.TunnelOpts, + ) + if err != nil { + return fmt.Errorf("failed to find routes to %s: %w", t.connInfo.Entity.IP, err) + } + } + + // Check if routes are okay (again). + if len(routes.All) == 0 { + return fmt.Errorf("no routes to %s", t.connInfo.Entity.IP) + } + + // Try routes until one succeeds. + log.Tracer(ctx).Trace("spn/crew: establishing route...") + var dstPin *navigator.Pin + var dstTerminal terminal.Terminal + for tries, route := range routes.All { + dstPin, dstTerminal, err = establishRoute(route) + if err != nil { + continue + } + + // Assign route data to tunnel. + t.dstPin = dstPin + t.dstTerminal = dstTerminal + t.route = route + t.failedTries = tries + + // Push changes to Pins and return. + navigator.Main.PushPinChanges() + return nil + } + + return fmt.Errorf("failed to establish a route to %s: %w", t.connInfo.Entity.IP, err) +} + +type hopCheck struct { + pin *navigator.Pin + route *navigator.Route + expansion *docks.ExpansionTerminal + authOp *access.AuthorizeOp + pingOp *PingOp +} + +func establishRoute(route *navigator.Route) (dstPin *navigator.Pin, dstTerminal terminal.Terminal, err error) { + connectLock.Lock() + defer connectLock.Unlock() + + // Check for path length. + if len(route.Path) < 1 { + return nil, nil, errors.New("path too short") + } + + // Check for failing hubs in path. + for _, hop := range route.Path[1:] { + if hop.Pin().GetState().Has(navigator.StateFailing) { + return nil, nil, fmt.Errorf("failing hub in path: %s", hop.Pin().Hub.Name()) + } + } + + // Get home hub. + previousHop, homeTerminal := navigator.Main.GetHome() + if previousHop == nil || homeTerminal == nil { + return nil, nil, navigator.ErrHomeHubUnset + } + // Convert to interface for later use. + var previousTerminal terminal.Terminal = homeTerminal + + // Check if first hub in path is the home hub. + if route.Path[0].HubID != previousHop.Hub.ID { + return nil, nil, errors.New("path start does not match home hub") + } + + // Check if path only exists of home hub. + if len(route.Path) == 1 { + return previousHop, previousTerminal, nil + } + + // TODO: Check what needs locking. + + // Build path and save created paths. + hopChecks := make([]*hopCheck, 0, len(route.Path)-1) + for i, hop := range route.Path[1:] { + // Check if we already have a connection to the Hub. + activeTerminal := hop.Pin().GetActiveTerminal() + if activeTerminal != nil { + // Ping terminal if not recently checked. + if activeTerminal.NeedsReachableCheck(1 * time.Minute) { + pingOp, tErr := NewPingOp(activeTerminal) + if tErr.IsError() { + return nil, nil, tErr.Wrap("failed start ping to %s", hop.Pin()) + } + // Add for checking results later. + hopChecks = append(hopChecks, &hopCheck{ + pin: hop.Pin(), + route: route.CopyUpTo(i + 2), + expansion: activeTerminal, + pingOp: pingOp, + }) + } + + previousHop = hop.Pin() + previousTerminal = activeTerminal + continue + } + + // Expand to next Hub. + expansion, authOp, tErr := expand(previousTerminal, previousHop, hop.Pin()) + if tErr != nil { + return nil, nil, tErr.Wrap("failed to expand to %s", hop.Pin()) + } + + // Add for checking results later. + hopChecks = append(hopChecks, &hopCheck{ + pin: hop.Pin(), + route: route.CopyUpTo(i + 2), + expansion: expansion, + authOp: authOp, + }) + + // Save previous pin for next loop or end. + previousHop = hop.Pin() + previousTerminal = expansion + } + + // Check results. + for _, check := range hopChecks { + switch { + case check.authOp != nil: + // Wait for authOp result. + select { + case tErr := <-check.authOp.Result: + switch { + case tErr.IsError(): + // There was a network or authentication error. + check.pin.MarkAsFailingFor(3 * time.Minute) + log.Warningf("spn/crew: failed to auth to %s: %s", check.pin.Hub, tErr) + return nil, nil, tErr.Wrap("failed to authenticate to %s: %w", check.pin.Hub, tErr) + + case tErr.Is(terminal.ErrExplicitAck): + // Authentication was successful. + + default: + // Authentication was aborted. + if tErr != nil { + tErr = terminal.ErrUnknownError + } + log.Warningf("spn/crew: auth to %s aborted with %s", check.pin.Hub, tErr) + return nil, nil, tErr.Wrap("authentication to %s aborted: %w", check.pin.Hub, tErr) + } + + case <-time.After(5 * time.Second): + // Mark as failing for just a minute, until server load may be less. + check.pin.MarkAsFailingFor(1 * time.Minute) + log.Warningf("spn/crew: auth to %s timed out", check.pin.Hub) + + return nil, nil, terminal.ErrTimeout.With("waiting for auth to %s", check.pin.Hub) + } + + // Add terminal extension to the map. + check.pin.SetActiveTerminal(&navigator.PinConnection{ + Terminal: check.expansion, + Route: check.route, + }) + check.expansion.MarkReachable() + log.Infof("spn/crew: added conn to %s via %s", check.pin, check.route) + + case check.pingOp != nil: + // Wait for ping result. + select { + case tErr := <-check.pingOp.Result: + if !tErr.Is(terminal.ErrExplicitAck) { + // Mark as failing long enough to expire connections and session and shutdown connections. + // TODO: Should we forcibly disconnect instead? + // TODO: This might also be triggered if a relay fails and ends the operation. + check.pin.MarkAsFailingFor(7 * time.Minute) + // Forget about existing active terminal, re-create if needed. + check.pin.SetActiveTerminal(nil) + log.Warningf("spn/crew: failed to check reachability of %s: %s", check.pin.Hub, tErr) + + return nil, nil, tErr.Wrap("failed to check reachability of %s: %w", check.pin.Hub, tErr) + } + + case <-time.After(5 * time.Second): + // Mark as failing for just a minute, until server load may be less. + check.pin.MarkAsFailingFor(1 * time.Minute) + // Forget about existing active terminal, re-create if needed. + check.pin.SetActiveTerminal(nil) + log.Warningf("spn/crew: reachability check to %s timed out", check.pin.Hub) + + return nil, nil, terminal.ErrTimeout.With("waiting for ping to %s", check.pin.Hub) + } + + check.expansion.MarkReachable() + log.Debugf("spn/crew: checked conn to %s via %s", check.pin.Hub, check.route) + + default: + log.Errorf("spn/crew: invalid hop check for %s", check.pin.Hub) + return nil, nil, terminal.ErrInternalError.With("invalid hop check") + } + } + + // Return last hop. + return previousHop, previousTerminal, nil +} + +func expand(fromTerminal terminal.Terminal, from, to *navigator.Pin) (expansion *docks.ExpansionTerminal, authOp *access.AuthorizeOp, tErr *terminal.Error) { + expansion, tErr = docks.ExpandTo(fromTerminal, to.Hub.ID, to.Hub) + if tErr != nil { + return nil, nil, tErr.Wrap("failed to expand to %s", to.Hub) + } + + authOp, tErr = access.AuthorizeToTerminal(expansion) + if tErr != nil { + expansion.Abandon(nil) + return nil, nil, tErr.Wrap("failed to authorize") + } + + log.Infof("spn/crew: expanded to %s (from %s)", to.Hub, from.Hub) + return expansion, authOp, nil +} + +// TunnelContext holds additional information about the tunnel to be added to a +// connection. +type TunnelContext struct { + Path []*TunnelContextHop + PathCost float32 + RoutingAlg string + + tunnel *Tunnel +} + +// GetExitNodeID returns the ID of the exit node. +// It returns an empty string in case no path exists. +func (tc *TunnelContext) GetExitNodeID() string { + if len(tc.Path) == 0 { + return "" + } + + return tc.Path[len(tc.Path)-1].ID +} + +// StopTunnel stops the tunnel. +func (tc *TunnelContext) StopTunnel() error { + if tc.tunnel != nil && tc.tunnel.conn != nil { + return tc.tunnel.conn.Close() + } + return nil +} + +// TunnelContextHop holds hop data for TunnelContext. +type TunnelContextHop struct { + ID string + Name string + IPv4 *TunnelContextHopIPInfo `json:",omitempty"` + IPv6 *TunnelContextHopIPInfo `json:",omitempty"` +} + +// TunnelContextHopIPInfo holds hop IP data for TunnelContextHop. +type TunnelContextHopIPInfo struct { + IP net.IP + Country string + ASN uint + ASOwner string +} + +func addTunnelContextToConnection(t *Tunnel) { + // Create and add basic info. + tunnelCtx := &TunnelContext{ + Path: make([]*TunnelContextHop, len(t.route.Path)), + PathCost: t.route.TotalCost, + RoutingAlg: t.route.Algorithm, + tunnel: t, + } + t.connInfo.TunnelContext = tunnelCtx + + // Add path info. + for i, hop := range t.route.Path { + // Add hub info. + hopCtx := &TunnelContextHop{ + ID: hop.HubID, + Name: hop.Pin().Hub.Info.Name, + } + tunnelCtx.Path[i] = hopCtx + // Add hub IPv4 info. + if hop.Pin().Hub.Info.IPv4 != nil { + hopCtx.IPv4 = &TunnelContextHopIPInfo{ + IP: hop.Pin().Hub.Info.IPv4, + } + if hop.Pin().LocationV4 != nil { + hopCtx.IPv4.Country = hop.Pin().LocationV4.Country.Code + hopCtx.IPv4.ASN = hop.Pin().LocationV4.AutonomousSystemNumber + hopCtx.IPv4.ASOwner = hop.Pin().LocationV4.AutonomousSystemOrganization + } + } + // Add hub IPv6 info. + if hop.Pin().Hub.Info.IPv6 != nil { + hopCtx.IPv6 = &TunnelContextHopIPInfo{ + IP: hop.Pin().Hub.Info.IPv6, + } + if hop.Pin().LocationV6 != nil { + hopCtx.IPv6.Country = hop.Pin().LocationV6.Country.Code + hopCtx.IPv6.ASN = hop.Pin().LocationV6.AutonomousSystemNumber + hopCtx.IPv6.ASOwner = hop.Pin().LocationV6.AutonomousSystemOrganization + } + } + } +} diff --git a/spn/crew/metrics.go b/spn/crew/metrics.go new file mode 100644 index 00000000..b9549d1e --- /dev/null +++ b/spn/crew/metrics.go @@ -0,0 +1,223 @@ +package crew + +import ( + "sync/atomic" + + "github.com/tevino/abool" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/metrics" +) + +var ( + connectOpCnt *metrics.Counter + connectOpCntError *metrics.Counter + connectOpCntBadRequest *metrics.Counter + connectOpCntCanceled *metrics.Counter + connectOpCntFailed *metrics.Counter + connectOpCntConnected *metrics.Counter + connectOpCntRateLimited *metrics.Counter + + connectOpIncomingBytes *metrics.Counter + connectOpOutgoingBytes *metrics.Counter + + connectOpTTCRDurationHistogram *metrics.Histogram + connectOpTTFBDurationHistogram *metrics.Histogram + connectOpDurationHistogram *metrics.Histogram + connectOpIncomingDataHistogram *metrics.Histogram + connectOpOutgoingDataHistogram *metrics.Histogram + + metricsRegistered = abool.New() +) + +func registerMetrics() (err error) { + // Only register metrics once. + if !metricsRegistered.SetToIf(false, true) { + return nil + } + + // Connect Op Stats on client. + + connectOpCnt, err = metrics.NewCounter( + "spn/op/connect/total", + nil, + &metrics.Options{ + Name: "SPN Total Connect Operations", + InternalID: "spn_connect_count", + Permission: api.PermitUser, + Persist: true, + }, + ) + if err != nil { + return err + } + + // Connect Op Stats on server. + + connectOpCntOptions := &metrics.Options{ + Name: "SPN Total Connect Operations", + Permission: api.PermitUser, + Persist: true, + } + + connectOpCntError, err = metrics.NewCounter( + "spn/op/connect/total", + map[string]string{"result": "error"}, + connectOpCntOptions, + ) + if err != nil { + return err + } + + connectOpCntBadRequest, err = metrics.NewCounter( + "spn/op/connect/total", + map[string]string{"result": "bad_request"}, + connectOpCntOptions, + ) + if err != nil { + return err + } + + connectOpCntCanceled, err = metrics.NewCounter( + "spn/op/connect/total", + map[string]string{"result": "canceled"}, + connectOpCntOptions, + ) + if err != nil { + return err + } + + connectOpCntFailed, err = metrics.NewCounter( + "spn/op/connect/total", + map[string]string{"result": "failed"}, + connectOpCntOptions, + ) + if err != nil { + return err + } + + connectOpCntConnected, err = metrics.NewCounter( + "spn/op/connect/total", + map[string]string{"result": "connected"}, + connectOpCntOptions, + ) + if err != nil { + return err + } + + connectOpCntRateLimited, err = metrics.NewCounter( + "spn/op/connect/total", + map[string]string{"result": "rate_limited"}, + connectOpCntOptions, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/op/connect/active", + nil, + getActiveConnectOpsStat, + &metrics.Options{ + Name: "SPN Active Connect Operations", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + connectOpIncomingBytes, err = metrics.NewCounter( + "spn/op/connect/incoming/bytes", + nil, + &metrics.Options{ + Name: "SPN Connect Operation Incoming Bytes", + InternalID: "spn_connect_in_bytes", + Permission: api.PermitUser, + Persist: true, + }, + ) + if err != nil { + return err + } + + connectOpOutgoingBytes, err = metrics.NewCounter( + "spn/op/connect/outgoing/bytes", + nil, + &metrics.Options{ + Name: "SPN Connect Operation Outgoing Bytes", + InternalID: "spn_connect_out_bytes", + Permission: api.PermitUser, + Persist: true, + }, + ) + if err != nil { + return err + } + + connectOpTTCRDurationHistogram, err = metrics.NewHistogram( + "spn/op/connect/histogram/ttcr/seconds", + nil, + &metrics.Options{ + Name: "SPN Connect Operation time-to-connect-request Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + connectOpTTFBDurationHistogram, err = metrics.NewHistogram( + "spn/op/connect/histogram/ttfb/seconds", + nil, + &metrics.Options{ + Name: "SPN Connect Operation time-to-first-byte (from TTCR) Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + connectOpDurationHistogram, err = metrics.NewHistogram( + "spn/op/connect/histogram/duration/seconds", + nil, + &metrics.Options{ + Name: "SPN Connect Operation Duration Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + connectOpIncomingDataHistogram, err = metrics.NewHistogram( + "spn/op/connect/histogram/incoming/bytes", + nil, + &metrics.Options{ + Name: "SPN Connect Operation Downloaded Data Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + connectOpOutgoingDataHistogram, err = metrics.NewHistogram( + "spn/op/connect/histogram/outgoing/bytes", + nil, + &metrics.Options{ + Name: "SPN Connect Operation Outgoing Data Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + return nil +} + +func getActiveConnectOpsStat() float64 { + return float64(atomic.LoadInt64(activeConnectOps)) +} diff --git a/spn/crew/module.go b/spn/crew/module.go new file mode 100644 index 00000000..10d4ebed --- /dev/null +++ b/spn/crew/module.go @@ -0,0 +1,44 @@ +package crew + +import ( + "time" + + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/terminal" +) + +var module *modules.Module + +func init() { + module = modules.Register("crew", nil, start, stop, "terminal", "docks", "navigator", "intel", "cabin") +} + +func start() error { + module.NewTask("sticky cleaner", cleanStickyHubs). + Repeat(10 * time.Minute) + + return registerMetrics() +} + +func stop() error { + clearStickyHubs() + terminal.StopScheduler() + + return nil +} + +var connectErrors = make(chan *terminal.Error, 10) + +func reportConnectError(tErr *terminal.Error) { + select { + case connectErrors <- tErr: + default: + } +} + +// ConnectErrors returns errors of connect operations. +// It only has a small and shared buffer and may only be used for indications, +// not for full monitoring. +func ConnectErrors() <-chan *terminal.Error { + return connectErrors +} diff --git a/spn/crew/module_test.go b/spn/crew/module_test.go new file mode 100644 index 00000000..7c0a7ad7 --- /dev/null +++ b/spn/crew/module_test.go @@ -0,0 +1,13 @@ +package crew + +import ( + "testing" + + "github.com/safing/portmaster/service/core/pmtesting" + "github.com/safing/portmaster/spn/conf" +) + +func TestMain(m *testing.M) { + conf.EnablePublicHub(true) + pmtesting.TestMain(m, module) +} diff --git a/spn/crew/op_connect.go b/spn/crew/op_connect.go new file mode 100644 index 00000000..0fc2174c --- /dev/null +++ b/spn/crew/op_connect.go @@ -0,0 +1,585 @@ +package crew + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "sync/atomic" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/terminal" +) + +// ConnectOpType is the type ID for the connection operation. +const ConnectOpType string = "connect" + +var activeConnectOps = new(int64) + +// ConnectOp is used to connect data tunnels to servers on the Internet. +type ConnectOp struct { + terminal.OperationBase + + // Flow Control + dfq *terminal.DuplexFlowQueue + + // Context and shutdown handling + // ctx is the context of the Terminal. + ctx context.Context + // cancelCtx cancels ctx. + cancelCtx context.CancelFunc + // doneWriting signals that the writer has finished writing. + doneWriting chan struct{} + + // Metrics + incomingTraffic atomic.Uint64 + outgoingTraffic atomic.Uint64 + started time.Time + + // Connection + t terminal.Terminal + conn net.Conn + request *ConnectRequest + entry bool + tunnel *Tunnel +} + +// Type returns the type ID. +func (op *ConnectOp) Type() string { + return ConnectOpType +} + +// Ctx returns the operation context. +func (op *ConnectOp) Ctx() context.Context { + return op.ctx +} + +// ConnectRequest holds all the information necessary for a connect operation. +type ConnectRequest struct { + Domain string `json:"d,omitempty"` + IP net.IP `json:"ip,omitempty"` + UsePriorityDataMsgs bool `json:"pr,omitempty"` + Protocol packet.IPProtocol `json:"p,omitempty"` + Port uint16 `json:"po,omitempty"` + QueueSize uint32 `json:"qs,omitempty"` +} + +// DialNetwork returns the address of the connect request. +func (r *ConnectRequest) DialNetwork() string { + if ip4 := r.IP.To4(); ip4 != nil { + switch r.Protocol { //nolint:exhaustive // Only looking for supported protocols. + case packet.TCP: + return "tcp4" + case packet.UDP: + return "udp4" + } + } else { + switch r.Protocol { //nolint:exhaustive // Only looking for supported protocols. + case packet.TCP: + return "tcp6" + case packet.UDP: + return "udp6" + } + } + + return "" +} + +// Address returns the address of the connext request. +func (r *ConnectRequest) Address() string { + return net.JoinHostPort(r.IP.String(), strconv.Itoa(int(r.Port))) +} + +func (r *ConnectRequest) String() string { + if r.Domain != "" { + return fmt.Sprintf("%s (%s %s)", r.Domain, r.Protocol, r.Address()) + } + return fmt.Sprintf("%s %s", r.Protocol, r.Address()) +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: ConnectOpType, + Requires: terminal.MayConnect, + Start: startConnectOp, + }) +} + +// NewConnectOp starts a new connect operation. +func NewConnectOp(tunnel *Tunnel) (*ConnectOp, *terminal.Error) { + // Submit metrics. + connectOpCnt.Inc() + + // Create request. + request := &ConnectRequest{ + Domain: tunnel.connInfo.Entity.Domain, + IP: tunnel.connInfo.Entity.IP, + Protocol: packet.IPProtocol(tunnel.connInfo.Entity.Protocol), + Port: tunnel.connInfo.Entity.Port, + UsePriorityDataMsgs: terminal.UsePriorityDataMsgs, + } + + // Set defaults. + if request.QueueSize == 0 { + request.QueueSize = terminal.DefaultQueueSize + } + + // Create new op. + op := &ConnectOp{ + doneWriting: make(chan struct{}), + t: tunnel.dstTerminal, + conn: tunnel.conn, + request: request, + entry: true, + tunnel: tunnel, + } + op.ctx, op.cancelCtx = context.WithCancel(module.Ctx) + op.dfq = terminal.NewDuplexFlowQueue(op.Ctx(), request.QueueSize, op.submitUpstream) + + // Prepare init msg. + data, err := dsd.Dump(request, dsd.CBOR) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to pack connect request: %w", err) + } + + // Initialize. + tErr := op.t.StartOperation(op, container.New(data), 5*time.Second) + if tErr != nil { + return nil, tErr + } + + // Setup metrics. + op.started = time.Now() + + module.StartWorker("connect op conn reader", op.connReader) + module.StartWorker("connect op conn writer", op.connWriter) + module.StartWorker("connect op flow handler", op.dfq.FlowHandler) + + log.Infof("spn/crew: connected to %s via %s", request, tunnel.dstPin.Hub) + return op, nil +} + +func startConnectOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Check if we are running a public hub. + if !conf.PublicHub() { + return nil, terminal.ErrPermissionDenied.With("connecting is only allowed on public hubs") + } + + // Parse connect request. + request := &ConnectRequest{} + _, err := dsd.Load(data.CompileData(), request) + if err != nil { + connectOpCntError.Inc() // More like a protocol/system error than a bad request. + return nil, terminal.ErrMalformedData.With("failed to parse connect request: %w", err) + } + if request.QueueSize == 0 || request.QueueSize > terminal.MaxQueueSize { + connectOpCntError.Inc() // More like a protocol/system error than a bad request. + return nil, terminal.ErrInvalidOptions.With("invalid queue size of %d", request.QueueSize) + } + + // Check if IP seems valid. + if len(request.IP) != net.IPv4len && len(request.IP) != net.IPv6len { + connectOpCntError.Inc() // More like a protocol/system error than a bad request. + return nil, terminal.ErrInvalidOptions.With("ip address is not valid") + } + + // Create and initialize operation. + op := &ConnectOp{ + doneWriting: make(chan struct{}), + t: t, + request: request, + } + op.InitOperationBase(t, opID) + op.ctx, op.cancelCtx = context.WithCancel(t.Ctx()) + op.dfq = terminal.NewDuplexFlowQueue(op.Ctx(), request.QueueSize, op.submitUpstream) + + // Start worker to complete setting up the connection. + module.StartWorker("connect op setup", op.handleSetup) + + return op, nil +} + +func (op *ConnectOp) handleSetup(_ context.Context) error { + // Get terminal session for rate limiting. + var session *terminal.Session + if sessionTerm, ok := op.t.(terminal.SessionTerminal); ok { + session = sessionTerm.GetSession() + } else { + connectOpCntError.Inc() + log.Errorf("spn/crew: %T is not a session terminal, aborting op %s#%d", op.t, op.t.FmtID(), op.ID()) + op.Stop(op, terminal.ErrInternalError.With("no session available")) + return nil + } + + // Limit concurrency of connecting. + cancelErr := session.LimitConcurrency(op.Ctx(), func() { + op.setup(session) + }) + + // If context was canceled, stop operation. + if cancelErr != nil { + connectOpCntCanceled.Inc() + op.Stop(op, terminal.ErrCanceled.With(cancelErr.Error())) + } + + // Do not return a worker error. + return nil +} + +func (op *ConnectOp) setup(session *terminal.Session) { + // Rate limit before connecting. + if tErr := session.RateLimit(); tErr != nil { + // Add rate limit info to error. + if tErr.Is(terminal.ErrRateLimited) { + connectOpCntRateLimited.Inc() + op.Stop(op, tErr.With(session.RateLimitInfo())) + return + } + + connectOpCntError.Inc() + op.Stop(op, tErr) + return + } + + // Check if connection target is in global scope. + ipScope := netutils.GetIPScope(op.request.IP) + if ipScope != netutils.Global { + session.ReportSuspiciousActivity(terminal.SusFactorQuiteUnusual) + connectOpCntBadRequest.Inc() + op.Stop(op, terminal.ErrPermissionDenied.With("denied request to connect to non-global IP %s", op.request.IP)) + return + } + + // Check exit policy. + if tErr := checkExitPolicy(op.request); tErr != nil { + session.ReportSuspiciousActivity(terminal.SusFactorQuiteUnusual) + connectOpCntBadRequest.Inc() + op.Stop(op, tErr) + return + } + + // Check one last time before connecting if operation was not canceled. + if op.Ctx().Err() != nil { + op.Stop(op, terminal.ErrCanceled.With(op.Ctx().Err().Error())) + connectOpCntCanceled.Inc() + return + } + + // Connect to destination. + dialNet := op.request.DialNetwork() + if dialNet == "" { + session.ReportSuspiciousActivity(terminal.SusFactorCommon) + connectOpCntBadRequest.Inc() + op.Stop(op, terminal.ErrIncorrectUsage.With("protocol %s is not supported", op.request.Protocol)) + return + } + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + LocalAddr: conf.GetBindAddr(dialNet), + FallbackDelay: -1, // Disables Fast Fallback from IPv6 to IPv4. + KeepAlive: -1, // Disable keep-alive. + } + conn, err := dialer.DialContext(op.Ctx(), dialNet, op.request.Address()) + if err != nil { + // Connection errors are common, but still a bit suspicious. + var netError net.Error + switch { + case errors.As(err, &netError) && netError.Timeout(): + session.ReportSuspiciousActivity(terminal.SusFactorCommon) + connectOpCntFailed.Inc() + case errors.Is(err, context.Canceled): + session.ReportSuspiciousActivity(terminal.SusFactorCommon) + connectOpCntCanceled.Inc() + default: + session.ReportSuspiciousActivity(terminal.SusFactorWeirdButOK) + connectOpCntFailed.Inc() + } + + op.Stop(op, terminal.ErrConnectionError.With("failed to connect to %s: %w", op.request, err)) + return + } + op.conn = conn + + // Start worker. + module.StartWorker("connect op conn reader", op.connReader) + module.StartWorker("connect op conn writer", op.connWriter) + module.StartWorker("connect op flow handler", op.dfq.FlowHandler) + + connectOpCntConnected.Inc() + log.Infof("spn/crew: connected op %s#%d to %s", op.t.FmtID(), op.ID(), op.request) +} + +func (op *ConnectOp) submitUpstream(msg *terminal.Msg, timeout time.Duration) { + err := op.Send(msg, timeout) + if err != nil { + msg.Finish() + op.Stop(op, err.Wrap("failed to send data (op) read from %s", op.connectedType())) + } +} + +const ( + readBufSize = 1500 + + // High priority up to first 10MB. + highPrioThreshold = 10_000_000 + + // Rate limit to 128 Mbit/s after 1GB traffic. + // Do NOT use time.Sleep per packet, as it is very inaccurate and will sleep a lot longer than desired. + rateLimitThreshold = 1_000_000_000 + rateLimitMaxMbit = 128 +) + +func (op *ConnectOp) connReader(_ context.Context) error { + // Metrics setup and submitting. + atomic.AddInt64(activeConnectOps, 1) + defer func() { + atomic.AddInt64(activeConnectOps, -1) + connectOpDurationHistogram.UpdateDuration(op.started) + connectOpIncomingDataHistogram.Update(float64(op.incomingTraffic.Load())) + }() + + rateLimiter := terminal.NewRateLimiter(rateLimitMaxMbit) + + for { + // Read from connection. + buf := make([]byte, readBufSize) + n, err := op.conn.Read(buf) + if err != nil { + if errors.Is(err, io.EOF) { + op.Stop(op, terminal.ErrStopping.With("connection to %s was closed on read", op.connectedType())) + } else { + op.Stop(op, terminal.ErrConnectionError.With("failed to read from %s: %w", op.connectedType(), err)) + } + return nil + } + if n == 0 { + log.Tracef("spn/crew: connect op %s>%d read 0 bytes from %s", op.t.FmtID(), op.ID(), op.connectedType()) + continue + } + + // Submit metrics. + connectOpIncomingBytes.Add(n) + inBytes := op.incomingTraffic.Add(uint64(n)) + + // Rate limit if over threshold. + if inBytes > rateLimitThreshold { + rateLimiter.Limit(uint64(n)) + } + + // Create message from data. + msg := op.NewMsg(buf[:n]) + + // Define priority and possibly wait for slot. + switch { + case inBytes > highPrioThreshold: + msg.Unit.WaitForSlot() + case op.request.UsePriorityDataMsgs: + msg.Unit.MakeHighPriority() + } + + // Send packet. + tErr := op.dfq.Send( + msg, + 30*time.Second, + ) + if tErr != nil { + msg.Finish() + op.Stop(op, tErr.Wrap("failed to send data (dfq) from %s", op.connectedType())) + return nil + } + } +} + +// Deliver delivers a messages to the operation. +func (op *ConnectOp) Deliver(msg *terminal.Msg) *terminal.Error { + return op.dfq.Deliver(msg) +} + +func (op *ConnectOp) connWriter(_ context.Context) error { + // Metrics submitting. + defer func() { + connectOpOutgoingDataHistogram.Update(float64(op.outgoingTraffic.Load())) + }() + + defer func() { + // Signal that we are done with writing. + close(op.doneWriting) + // Close connection. + _ = op.conn.Close() + }() + + var msg *terminal.Msg + defer msg.Finish() + + rateLimiter := terminal.NewRateLimiter(rateLimitMaxMbit) + +writing: + for { + msg.Finish() + + select { + case msg = <-op.dfq.Receive(): + case <-op.ctx.Done(): + op.Stop(op, terminal.ErrCanceled) + return nil + default: + // Handle all data before also listening for the context cancel. + // This ensures all data is written properly before stopping. + select { + case msg = <-op.dfq.Receive(): + case op.doneWriting <- struct{}{}: + op.Stop(op, terminal.ErrStopping) + return nil + case <-op.ctx.Done(): + op.Stop(op, terminal.ErrCanceled) + return nil + } + } + + // TODO: Instead of compiling data here again, can we send it as in the container? + data := msg.Data.CompileData() + if len(data) == 0 { + continue writing + } + + // Submit metrics. + connectOpOutgoingBytes.Add(len(data)) + out := op.outgoingTraffic.Add(uint64(len(data))) + + // Rate limit if over threshold. + if out > rateLimitThreshold { + rateLimiter.Limit(uint64(len(data))) + } + + // Special handling after first data was received on client. + if op.entry && + out == uint64(len(data)) { + // Report time taken to receive first byte. + connectOpTTFBDurationHistogram.UpdateDuration(op.started) + + // If not stickied yet, stick destination to Hub. + if !op.tunnel.stickied { + op.tunnel.stickDestinationToHub() + } + } + + // Send all given data. + for { + n, err := op.conn.Write(data) + switch { + case err != nil: + if errors.Is(err, io.EOF) { + op.Stop(op, terminal.ErrStopping.With("connection to %s was closed on write", op.connectedType())) + } else { + op.Stop(op, terminal.ErrConnectionError.With("failed to send to %s: %w", op.connectedType(), err)) + } + return nil + case n == 0: + op.Stop(op, terminal.ErrConnectionError.With("sent 0 bytes to %s", op.connectedType())) + return nil + case n < len(data): + // If not all data was sent, try again. + log.Debugf("spn/crew: %s#%d only sent %d/%d bytes to %s", op.t.FmtID(), op.ID(), n, len(data), op.connectedType()) + data = data[n:] + default: + continue writing + } + } + } +} + +func (op *ConnectOp) connectedType() string { + if op.entry { + return "origin" + } + return "destination" +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *ConnectOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + if err.IsError() { + reportConnectError(err) + } + + // If the connection has sent or received any data so far, finish the data + // flows as it makes sense. + if op.incomingTraffic.Load() > 0 || op.outgoingTraffic.Load() > 0 { + // If the op was ended locally, send all data before closing. + // If the op was ended remotely, don't bother sending remaining data. + if !err.IsExternal() { + // Flushing could mean sending a full buffer of 50000 packets. + op.dfq.Flush(5 * time.Minute) + } + + // If the op was ended remotely, write all remaining received data. + // If the op was ended locally, don't bother writing remaining data. + if err.IsExternal() { + select { + case <-op.doneWriting: + default: + select { + case <-op.doneWriting: + case <-time.After(5 * time.Second): + } + } + } + } + + // Cancel workers. + op.cancelCtx() + + // Special client-side handling. + if op.entry { + // Mark the connection as failed if there was an error and no data was sent to the app yet. + if err.IsError() && op.outgoingTraffic.Load() == 0 { + // Set connection to failed and save it to propagate the update. + c := op.tunnel.connInfo + func() { + c.Lock() + defer c.Unlock() + + if err.IsExternal() { + c.Failed(fmt.Sprintf( + "the exit node reported an error: %s", err, + ), "") + } else { + c.Failed(fmt.Sprintf( + "connection failed locally: %s", err, + ), "") + } + + c.Save() + }() + } + + // Avoid connecting to the destination via this Hub if: + // - The error is external - ie. from the server. + // - The error is a connection error. + // - No data was received. + // This indicates that there is some network level issue that we can + // possibly work around by using another exit node. + if err.IsError() && err.IsExternal() && + err.Is(terminal.ErrConnectionError) && + op.outgoingTraffic.Load() == 0 { + op.tunnel.avoidDestinationHub() + } + + // Don't leak local errors to the server. + if !err.IsExternal() { + // Change error that is reported. + return terminal.ErrStopping + } + } + + return err +} diff --git a/spn/crew/op_connect_test.go b/spn/crew/op_connect_test.go new file mode 100644 index 00000000..7205ea9a --- /dev/null +++ b/spn/crew/op_connect_test.go @@ -0,0 +1,115 @@ +package crew + +import ( + "fmt" + "net" + "net/http" + "net/url" + "testing" + "time" + + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/navigator" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + testPadding = 8 + testQueueSize = 10 +) + +func TestConnectOp(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping test in short mode, as it interacts with the network") + } + + // Create test terminal pair. + a, b, err := terminal.NewSimpleTestTerminalPair(0, 0, + &terminal.TerminalOpts{ + FlowControl: terminal.FlowControlDFQ, + FlowControlSize: testQueueSize, + Padding: testPadding, + }, + ) + if err != nil { + t.Fatalf("failed to create test terminal pair: %s", err) + } + + // Set up connect op. + b.GrantPermission(terminal.MayConnect) + conf.EnablePublicHub(true) + identity, err := cabin.CreateIdentity(module.Ctx, "test") + if err != nil { + t.Fatalf("failed to create identity: %s", err) + } + _, err = identity.MaintainAnnouncement(&hub.Announcement{ + Transports: []string{ + "tcp:17", + }, + Exit: []string{ + "+ * */80", + "- *", + }, + }, true) + if err != nil { + t.Fatalf("failed to update identity: %s", err) + } + EnableConnecting(identity.Hub) + + for i := 0; i < 1; i++ { + appConn, sluiceConn := net.Pipe() + _, tErr := NewConnectOp(&Tunnel{ + connInfo: &network.Connection{ + Entity: (&intel.Entity{ + Protocol: 6, + Port: 80, + Domain: "orf.at.", + IP: net.IPv4(194, 232, 104, 142), + }).Init(0), + }, + conn: sluiceConn, + dstTerminal: a, + dstPin: &navigator.Pin{ + Hub: identity.Hub, + }, + }) + if tErr != nil { + t.Fatalf("failed to start connect op: %s", tErr) + } + + // Send request. + requestURL, err := url.Parse("http://orf.at/") + if err != nil { + t.Fatalf("failed to parse request url: %s", err) + } + r := http.Request{ + Method: http.MethodHead, + URL: requestURL, + } + err = r.Write(appConn) + if err != nil { + t.Fatalf("failed to write request: %s", err) + } + + // Recv response. + data := make([]byte, 1500) + n, err := appConn.Read(data) + if err != nil { + t.Fatalf("failed to read request: %s", err) + } + if n == 0 { + t.Fatal("received empty reply") + } + + t.Log("received data:") + fmt.Println(string(data[:n])) + + time.Sleep(500 * time.Millisecond) + } +} diff --git a/spn/crew/op_ping.go b/spn/crew/op_ping.go new file mode 100644 index 00000000..84ee4f6e --- /dev/null +++ b/spn/crew/op_ping.go @@ -0,0 +1,149 @@ +package crew + +import ( + "crypto/subtle" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + // PingOpType is the type ID of the latency test operation. + PingOpType = "ping" + + pingOpNonceSize = 16 + pingOpTimeout = 3 * time.Second +) + +// PingOp is used to measure latency. +type PingOp struct { + terminal.OneOffOperationBase + + started time.Time + nonce []byte +} + +// PingOpRequest is a ping request. +type PingOpRequest struct { + Nonce []byte `json:"n,omitempty"` +} + +// PingOpResponse is a ping response. +type PingOpResponse struct { + Nonce []byte `json:"n,omitempty"` + Time time.Time `json:"t,omitempty"` +} + +// Type returns the type ID. +func (op *PingOp) Type() string { + return PingOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: PingOpType, + Start: startPingOp, + }) +} + +// NewPingOp runs a latency test. +func NewPingOp(t terminal.Terminal) (*PingOp, *terminal.Error) { + // Generate nonce. + nonce, err := rng.Bytes(pingOpNonceSize) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to generate ping nonce: %w", err) + } + + // Create operation and init. + op := &PingOp{ + started: time.Now().UTC(), + nonce: nonce, + } + op.OneOffOperationBase.Init() + + // Create request. + pingRequest, err := dsd.Dump(&PingOpRequest{ + Nonce: op.nonce, + }, dsd.CBOR) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to create ping request: %w", err) + } + + // Send ping. + tErr := t.StartOperation(op, container.New(pingRequest), pingOpTimeout) + if tErr != nil { + return nil, tErr + } + + return op, nil +} + +// Deliver delivers a message to the operation. +func (op *PingOp) Deliver(msg *terminal.Msg) *terminal.Error { + defer msg.Finish() + + // Parse response. + response := &PingOpResponse{} + _, err := dsd.Load(msg.Data.CompileData(), response) + if err != nil { + return terminal.ErrMalformedData.With("failed to parse ping response: %w", err) + } + + // Check if the nonce matches. + if subtle.ConstantTimeCompare(op.nonce, response.Nonce) != 1 { + return terminal.ErrIntegrity.With("ping nonce mismatched") + } + + return terminal.ErrExplicitAck +} + +func startPingOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Parse request. + request := &PingOpRequest{} + _, err := dsd.Load(data.CompileData(), request) + if err != nil { + return nil, terminal.ErrMalformedData.With("failed to parse ping request: %w", err) + } + + // Create response. + response, err := dsd.Dump(&PingOpResponse{ + Nonce: request.Nonce, + Time: time.Now().UTC(), + }, dsd.CBOR) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to create ping response: %w", err) + } + + // Send response. + msg := terminal.NewMsg(response) + msg.FlowID = opID + msg.Unit.MakeHighPriority() + if terminal.UsePriorityDataMsgs { + msg.Type = terminal.MsgTypePriorityData + } + tErr := t.Send(msg, pingOpTimeout) + if tErr != nil { + // Finish message unit on failure. + msg.Finish() + return nil, tErr.With("failed to send ping response") + } + + // Operation is just one response and finished successfully. + return nil, nil +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *PingOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + // Prevent remote from sending explicit ack, as we use it as a success signal internally. + if err.Is(terminal.ErrExplicitAck) && err.IsExternal() { + err = terminal.ErrStopping.AsExternal() + } + + // Continue with usual handling of inherited base. + return op.OneOffOperationBase.HandleStop(err) +} diff --git a/spn/crew/op_ping_test.go b/spn/crew/op_ping_test.go new file mode 100644 index 00000000..f9d6dfb4 --- /dev/null +++ b/spn/crew/op_ping_test.go @@ -0,0 +1,32 @@ +package crew + +import ( + "testing" + "time" + + "github.com/safing/portmaster/spn/terminal" +) + +func TestPingOp(t *testing.T) { + t.Parallel() + + // Create test terminal pair. + a, _, err := terminal.NewSimpleTestTerminalPair(0, 0, nil) + if err != nil { + t.Fatalf("failed to create test terminal pair: %s", err) + } + + // Create ping op. + op, tErr := NewPingOp(a) + if tErr.IsError() { + t.Fatal(tErr) + } + + // Wait for result. + select { + case result := <-op.Result: + t.Logf("ping result: %s", result.Error()) + case <-time.After(pingOpTimeout): + t.Fatal("timed out") + } +} diff --git a/spn/crew/policy.go b/spn/crew/policy.go new file mode 100644 index 00000000..5a741164 --- /dev/null +++ b/spn/crew/policy.go @@ -0,0 +1,51 @@ +package crew + +import ( + "context" + "sync" + + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/terminal" +) + +var ( + connectingHubLock sync.Mutex + connectingHub *hub.Hub +) + +// EnableConnecting enables connecting from this Hub. +func EnableConnecting(my *hub.Hub) { + connectingHubLock.Lock() + defer connectingHubLock.Unlock() + + connectingHub = my +} + +func checkExitPolicy(request *ConnectRequest) *terminal.Error { + connectingHubLock.Lock() + defer connectingHubLock.Unlock() + + // Check if connect requests are allowed. + if connectingHub == nil { + return terminal.ErrPermissionDenied.With("connect requests disabled") + } + + // Create entity. + entity := (&intel.Entity{ + IP: request.IP, + Protocol: uint8(request.Protocol), + Port: request.Port, + Domain: request.Domain, + }).Init(0) + entity.FetchData(context.TODO()) + + // Check against policy. + result, reason := connectingHub.GetInfo().ExitPolicy().Match(context.TODO(), entity) + if result == endpoints.Denied { + return terminal.ErrPermissionDenied.With("connect request for %s violates the exit policy: %s", request, reason) + } + + return nil +} diff --git a/spn/crew/sticky.go b/spn/crew/sticky.go new file mode 100644 index 00000000..598476fa --- /dev/null +++ b/spn/crew/sticky.go @@ -0,0 +1,176 @@ +package crew + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" + "github.com/safing/portmaster/spn/navigator" +) + +const ( + stickyTTL = 1 * time.Hour +) + +var ( + stickyIPs = make(map[string]*stickyHub) + stickyDomains = make(map[string]*stickyHub) + stickyLock sync.Mutex +) + +type stickyHub struct { + Pin *navigator.Pin + Route *navigator.Route + LastSeen time.Time + Avoid bool +} + +func (sh *stickyHub) isExpired() bool { + return time.Now().Add(-stickyTTL).After(sh.LastSeen) +} + +func makeStickyIPKey(conn *network.Connection) string { + if p := conn.Process().Profile(); p != nil { + return fmt.Sprintf( + "%s/%s>%s", + p.LocalProfile().Source, + p.LocalProfile().ID, + conn.Entity.IP, + ) + } + + return "?>" + string(conn.Entity.IP) +} + +func makeStickyDomainKey(conn *network.Connection) string { + if p := conn.Process().Profile(); p != nil { + return fmt.Sprintf( + "%s/%s>%s", + p.LocalProfile().Source, + p.LocalProfile().ID, + conn.Entity.Domain, + ) + } + + return "?>" + conn.Entity.Domain +} + +func getStickiedHub(conn *network.Connection) (sticksTo *stickyHub) { + stickyLock.Lock() + defer stickyLock.Unlock() + + // Check if IP is sticky. + sticksTo = stickyIPs[makeStickyIPKey(conn)] // byte comparison + if sticksTo != nil && !sticksTo.isExpired() { + sticksTo.LastSeen = time.Now() + } + + // If the IP did not stick and we have a domain, check if that sticks. + if sticksTo == nil && conn.Entity.Domain != "" { + sticksTo, ok := stickyDomains[makeStickyDomainKey(conn)] + if ok && !sticksTo.isExpired() { + sticksTo.LastSeen = time.Now() + } + } + + // If nothing sticked, return now. + if sticksTo == nil { + return nil + } + + // Get intel from map before locking pin to avoid simultaneous locking. + mapIntel := navigator.Main.GetIntel() + + // Lock Pin for checking. + sticksTo.Pin.Lock() + defer sticksTo.Pin.Unlock() + + // Check if the stickied Hub supports the needed IP version. + switch { + case conn.IPVersion == packet.IPv4 && sticksTo.Pin.EntityV4 == nil: + // Connection is IPv4, but stickied Hub has no IPv4. + return nil + case conn.IPVersion == packet.IPv6 && sticksTo.Pin.EntityV6 == nil: + // Connection is IPv4, but stickied Hub has no IPv4. + return nil + } + + // Disregard stickied Hub if it is disregard with the current options. + matcher := conn.TunnelOpts.Destination.Matcher(mapIntel) + if !matcher(sticksTo.Pin) { + return nil + } + + // Return fully checked stickied Hub. + return sticksTo +} + +func (t *Tunnel) stickDestinationToHub() { + stickyLock.Lock() + defer stickyLock.Unlock() + + // Stick to IP. + ipKey := makeStickyIPKey(t.connInfo) + stickyIPs[ipKey] = &stickyHub{ + Pin: t.dstPin, + Route: t.route, + LastSeen: time.Now(), + } + log.Infof("spn/crew: sticking %s to %s", ipKey, t.dstPin.Hub) + + // Stick to Domain, if present. + if t.connInfo.Entity.Domain != "" { + domainKey := makeStickyDomainKey(t.connInfo) + stickyDomains[domainKey] = &stickyHub{ + Pin: t.dstPin, + Route: t.route, + LastSeen: time.Now(), + } + log.Infof("spn/crew: sticking %s to %s", domainKey, t.dstPin.Hub) + } +} + +func (t *Tunnel) avoidDestinationHub() { + stickyLock.Lock() + defer stickyLock.Unlock() + + // Stick to Hub/IP Pair. + ipKey := makeStickyIPKey(t.connInfo) + stickyIPs[ipKey] = &stickyHub{ + Pin: t.dstPin, + LastSeen: time.Now(), + Avoid: true, + } + log.Warningf("spn/crew: avoiding %s for %s", t.dstPin.Hub, ipKey) +} + +func cleanStickyHubs(ctx context.Context, task *modules.Task) error { + stickyLock.Lock() + defer stickyLock.Unlock() + + for _, stickyRegistry := range []map[string]*stickyHub{stickyIPs, stickyDomains} { + for key, stickedEntry := range stickyRegistry { + if stickedEntry.isExpired() { + delete(stickyRegistry, key) + } + } + } + + return nil +} + +func clearStickyHubs() { + stickyLock.Lock() + defer stickyLock.Unlock() + + for _, stickyRegistry := range []map[string]*stickyHub{stickyIPs, stickyDomains} { + for key := range stickyRegistry { + delete(stickyRegistry, key) + } + } +} diff --git a/spn/docks/bandwidth_test.go b/spn/docks/bandwidth_test.go new file mode 100644 index 00000000..60101f1c --- /dev/null +++ b/spn/docks/bandwidth_test.go @@ -0,0 +1,90 @@ +package docks + +import ( + "testing" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portmaster/spn/terminal" +) + +func TestEffectiveBandwidth(t *testing.T) { //nolint:paralleltest // Run alone. + // Skip in CI. + if testing.Short() { + t.Skip() + } + + var ( + bwTestDelay = 50 * time.Millisecond + bwTestQueueSize uint32 = 1000 + bwTestVolume = 10000000 // 10MB + bwTestTime = 10 * time.Second + ) + + // Create test terminal pair. + a, b, err := terminal.NewSimpleTestTerminalPair( + bwTestDelay, + int(bwTestQueueSize), + &terminal.TerminalOpts{ + FlowControl: terminal.FlowControlDFQ, + FlowControlSize: bwTestQueueSize, + }, + ) + if err != nil { + t.Fatalf("failed to create test terminal pair: %s", err) + } + + // Grant permission for op on remote terminal and start op. + b.GrantPermission(terminal.IsCraneController) + + // Re-use the capacity test for the bandwidth test. + op := &CapacityTestOp{ + opts: &CapacityTestOptions{ + TestVolume: bwTestVolume, + MaxTime: bwTestTime, + testing: true, + }, + recvQueue: make(chan *terminal.Msg), + dataSent: new(int64), + dataSentWasAckd: abool.New(), + result: make(chan *terminal.Error, 1), + } + // Disable sender again. + op.senderStarted = true + op.dataSentWasAckd.Set() + // Make capacity test request. + request, err := dsd.Dump(op.opts, dsd.CBOR) + if err != nil { + t.Fatal(terminal.ErrInternalError.With("failed to serialize capactity test options: %w", err)) + } + // Send test request. + tErr := a.StartOperation(op, container.New(request), 1*time.Second) + if tErr != nil { + t.Fatal(tErr) + } + // Start handler. + module.StartWorker("op capacity handler", op.handler) + + // Wait for result and check error. + tErr = <-op.Result() + if !tErr.IsOK() { + t.Fatalf("op failed: %s", tErr) + } + t.Logf("measured capacity: %d bit/s", op.testResult) + + // Calculate expected bandwidth. + expectedBitsPerSecond := (float64(capacityTestMsgSize*8*int64(bwTestQueueSize)) / float64(bwTestDelay)) * float64(time.Second) + t.Logf("expected capacity: %f bit/s", expectedBitsPerSecond) + + // Check if measured bandwidth is within parameters. + if float64(op.testResult) > expectedBitsPerSecond*1.6 { + t.Fatal("measured capacity too high") + } + // TODO: Check if we can raise this to at least 90%. + if float64(op.testResult) < expectedBitsPerSecond*0.2 { + t.Fatal("measured capacity too low") + } +} diff --git a/spn/docks/controller.go b/spn/docks/controller.go new file mode 100644 index 00000000..05e18e39 --- /dev/null +++ b/spn/docks/controller.go @@ -0,0 +1,100 @@ +package docks + +import ( + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/terminal" +) + +// CraneControllerTerminal is a terminal for the crane itself. +type CraneControllerTerminal struct { + *terminal.TerminalBase + + Crane *Crane +} + +// NewLocalCraneControllerTerminal returns a new local crane controller. +func NewLocalCraneControllerTerminal( + crane *Crane, + initMsg *terminal.TerminalOpts, +) (*CraneControllerTerminal, *container.Container, *terminal.Error) { + // Remove unnecessary options from the crane controller. + initMsg.Padding = 0 + + // Create Terminal Base. + t, initData, err := terminal.NewLocalBaseTerminal( + crane.ctx, + 0, + crane.ID, + nil, + initMsg, + terminal.UpstreamSendFunc(crane.sendImportantTerminalMsg), + ) + if err != nil { + return nil, nil, err + } + + return initCraneController(crane, t, initMsg), initData, nil +} + +// NewRemoteCraneControllerTerminal returns a new remote crane controller. +func NewRemoteCraneControllerTerminal( + crane *Crane, + initData *container.Container, +) (*CraneControllerTerminal, *terminal.TerminalOpts, *terminal.Error) { + // Create Terminal Base. + t, initMsg, err := terminal.NewRemoteBaseTerminal( + crane.ctx, + 0, + crane.ID, + nil, + initData, + terminal.UpstreamSendFunc(crane.sendImportantTerminalMsg), + ) + if err != nil { + return nil, nil, err + } + + return initCraneController(crane, t, initMsg), initMsg, nil +} + +func initCraneController( + crane *Crane, + t *terminal.TerminalBase, + initMsg *terminal.TerminalOpts, +) *CraneControllerTerminal { + // Create Crane Terminal and assign it as the extended Terminal. + cct := &CraneControllerTerminal{ + TerminalBase: t, + Crane: crane, + } + t.SetTerminalExtension(cct) + + // Assign controller to crane. + crane.Controller = cct + crane.terminals[cct.ID()] = cct + + // Copy the options to the crane itself. + crane.opts = *initMsg + + // Grant crane controller permission. + t.GrantPermission(terminal.IsCraneController) + + // Start workers. + t.StartWorkers(module, "crane controller terminal") + + return cct +} + +// HandleAbandon gives the terminal the ability to cleanly shut down. +func (controller *CraneControllerTerminal) HandleAbandon(err *terminal.Error) (errorToSend *terminal.Error) { + // Abandon terminal. + controller.Crane.AbandonTerminal(0, err) + + return err +} + +// HandleDestruction gives the terminal the ability to clean up. +func (controller *CraneControllerTerminal) HandleDestruction(err *terminal.Error) { + // Stop controlled crane. + controller.Crane.Stop(nil) +} diff --git a/spn/docks/crane.go b/spn/docks/crane.go new file mode 100644 index 00000000..34dab6d3 --- /dev/null +++ b/spn/docks/crane.go @@ -0,0 +1,913 @@ +package docks + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/tevino/abool" + + "github.com/safing/jess" + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/log" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + // QOTD holds the quote of the day to return on idling unused connections. + QOTD = "Privacy is not an option, and it shouldn't be the price we accept for just getting on the Internet.\nGary Kovacs\n" + + // maxUnloadSize defines the maximum size of a message to unload. + maxUnloadSize = 16384 + maxSegmentLength = 16384 + maxCraneStoppingDuration = 6 * time.Hour + maxCraneStopDuration = 10 * time.Second +) + +var ( + // optimalMinLoadSize defines minimum for Crane.targetLoadSize. + optimalMinLoadSize = 3072 // Targeting around 4096. + + // loadingMaxWaitDuration is the maximum time a crane will wait for + // additional data to send. + loadingMaxWaitDuration = 5 * time.Millisecond +) + +// Errors. +var ( + ErrDone = errors.New("crane is done") +) + +// Crane is the primary duplexer and connection manager. +type Crane struct { + // ID is the ID of the Crane. + ID string + // opts holds options. + opts terminal.TerminalOpts + + // ctx is the context of the Terminal. + ctx context.Context + // cancelCtx cancels ctx. + cancelCtx context.CancelFunc + // stopping indicates if the Crane will be stopped soon. The Crane may still + // be used until stopped, but must not be advertised anymore. + stopping *abool.AtomicBool + // stopped indicates if the Crane has been stopped. Whoever stopped the Crane + // already took care of notifying everyone, so a silent fail is normally the + // best response. + stopped *abool.AtomicBool + // authenticated indicates if there is has been any successful authentication. + authenticated *abool.AtomicBool + + // ConnectedHub is the identity of the remote Hub. + ConnectedHub *hub.Hub + // NetState holds the network optimization state. + // It must always be set and the reference must not be changed. + // Access to fields within are coordinated by itself. + NetState *NetworkOptimizationState + // identity is identity of this instance and is usually only populated on a server. + identity *cabin.Identity + + // jession is the jess session used for encryption. + jession *jess.Session + // jessionLock locks jession. + jessionLock sync.Mutex + + // Controller is the Crane's Controller Terminal. + Controller *CraneControllerTerminal + + // ship represents the underlying physical connection. + ship ships.Ship + // unloading moves containers from the ship to the crane. + unloading chan *container.Container + // loading moves containers from the crane to the ship. + loading chan *container.Container + // terminalMsgs holds containers from terminals waiting to be laoded. + terminalMsgs chan *terminal.Msg + // controllerMsgs holds important containers from terminals waiting to be laoded. + controllerMsgs chan *terminal.Msg + + // terminals holds all the connected terminals. + terminals map[uint32]terminal.Terminal + // terminalsLock locks terminals. + terminalsLock sync.Mutex + // nextTerminalID holds the next terminal ID. + nextTerminalID uint32 + + // targetLoadSize defines the optimal loading size. + targetLoadSize int +} + +// NewCrane returns a new crane. +func NewCrane(ship ships.Ship, connectedHub *hub.Hub, id *cabin.Identity) (*Crane, error) { + // Cranes always run in module context. + ctx, cancelCtx := context.WithCancel(module.Ctx) + + newCrane := &Crane{ + ctx: ctx, + cancelCtx: cancelCtx, + stopping: abool.NewBool(false), + stopped: abool.NewBool(false), + authenticated: abool.NewBool(false), + + ConnectedHub: connectedHub, + NetState: newNetworkOptimizationState(), + identity: id, + + ship: ship, + unloading: make(chan *container.Container), + loading: make(chan *container.Container, 100), + terminalMsgs: make(chan *terminal.Msg, 100), + controllerMsgs: make(chan *terminal.Msg, 100), + + terminals: make(map[uint32]terminal.Terminal), + } + err := registerCrane(newCrane) + if err != nil { + return nil, fmt.Errorf("failed to register crane: %w", err) + } + + // Shift next terminal IDs on the server. + if !ship.IsMine() { + newCrane.nextTerminalID += 4 + } + + // Calculate target load size. + loadSize := ship.LoadSize() + if loadSize <= 0 { + loadSize = ships.BaseMTU + } + newCrane.targetLoadSize = loadSize + for newCrane.targetLoadSize < optimalMinLoadSize { + newCrane.targetLoadSize += loadSize + } + // Subtract overhead needed for encryption. + newCrane.targetLoadSize -= 25 // Manually tested for jess.SuiteWireV1 + // Subtract space needed for length encoding the final chunk. + newCrane.targetLoadSize -= varint.EncodedSize(uint64(newCrane.targetLoadSize)) + + return newCrane, nil +} + +// IsMine returns whether the crane was started on this side. +func (crane *Crane) IsMine() bool { + return crane.ship.IsMine() +} + +// Public returns whether the crane has been published. +func (crane *Crane) Public() bool { + return crane.ship.Public() +} + +// IsStopping returns whether the crane is stopping. +func (crane *Crane) IsStopping() bool { + return crane.stopping.IsSet() +} + +// MarkStoppingRequested marks the crane as stopping requested. +func (crane *Crane) MarkStoppingRequested() { + crane.NetState.lock.Lock() + defer crane.NetState.lock.Unlock() + + if !crane.NetState.stoppingRequested { + crane.NetState.stoppingRequested = true + crane.startSyncStateOp() + } +} + +// MarkStopping marks the crane as stopping. +func (crane *Crane) MarkStopping() (stopping bool) { + // Can only stop owned cranes. + if !crane.IsMine() { + return false + } + + if !crane.stopping.SetToIf(false, true) { + return false + } + + crane.NetState.lock.Lock() + defer crane.NetState.lock.Unlock() + crane.NetState.markedStoppingAt = time.Now() + + crane.startSyncStateOp() + return true +} + +// AbortStopping aborts the stopping. +func (crane *Crane) AbortStopping() (aborted bool) { + aborted = crane.stopping.SetToIf(true, false) + + crane.NetState.lock.Lock() + defer crane.NetState.lock.Unlock() + + abortedStoppingRequest := crane.NetState.stoppingRequested + crane.NetState.stoppingRequested = false + crane.NetState.markedStoppingAt = time.Time{} + + // Sync if any state changed. + if aborted || abortedStoppingRequest { + crane.startSyncStateOp() + } + + return aborted +} + +// Authenticated returns whether the other side of the crane has authenticated +// itself with an access code. +func (crane *Crane) Authenticated() bool { + return crane.authenticated.IsSet() +} + +// Publish publishes the connection as a lane. +func (crane *Crane) Publish() error { + // Check if crane is connected. + if crane.ConnectedHub == nil { + return fmt.Errorf("spn/docks: %s: cannot publish without defined connected hub", crane) + } + + // Submit metrics. + if !crane.Public() { + newPublicCranes.Inc() + } + + // Mark crane as public. + maskedID := crane.ship.MaskAddress(crane.ship.RemoteAddr()) + crane.ship.MarkPublic() + + // Assign crane to make it available to others. + AssignCrane(crane.ConnectedHub.ID, crane) + + log.Infof("spn/docks: %s (was %s) is now public", crane, maskedID) + return nil +} + +// LocalAddr returns ship's local address. +func (crane *Crane) LocalAddr() net.Addr { + return crane.ship.LocalAddr() +} + +// RemoteAddr returns ship's local address. +func (crane *Crane) RemoteAddr() net.Addr { + return crane.ship.RemoteAddr() +} + +// Transport returns ship's transport. +func (crane *Crane) Transport() *hub.Transport { + return crane.ship.Transport() +} + +func (crane *Crane) getNextTerminalID() uint32 { + crane.terminalsLock.Lock() + defer crane.terminalsLock.Unlock() + + for { + // Bump to next ID. + crane.nextTerminalID += 8 + + // Check if it's free. + _, ok := crane.terminals[crane.nextTerminalID] + if !ok { + return crane.nextTerminalID + } + } +} + +func (crane *Crane) terminalCount() int { + crane.terminalsLock.Lock() + defer crane.terminalsLock.Unlock() + + return len(crane.terminals) +} + +func (crane *Crane) getTerminal(id uint32) (t terminal.Terminal, ok bool) { + crane.terminalsLock.Lock() + defer crane.terminalsLock.Unlock() + + t, ok = crane.terminals[id] + return +} + +func (crane *Crane) setTerminal(t terminal.Terminal) { + crane.terminalsLock.Lock() + defer crane.terminalsLock.Unlock() + + crane.terminals[t.ID()] = t +} + +func (crane *Crane) deleteTerminal(id uint32) (t terminal.Terminal, ok bool) { + crane.terminalsLock.Lock() + defer crane.terminalsLock.Unlock() + + t, ok = crane.terminals[id] + if ok { + delete(crane.terminals, id) + return t, true + } + return nil, false +} + +// AbandonTerminal abandons the terminal with the given ID. +func (crane *Crane) AbandonTerminal(id uint32, err *terminal.Error) { + // Get active terminal. + t, ok := crane.deleteTerminal(id) + if ok { + // If the terminal was registered, abandon it. + + // Log reason the terminal is ending. Override stopping error with nil. + switch { + case err == nil || err.IsOK(): + log.Debugf("spn/docks: %T %s is being abandoned", t, t.FmtID()) + case err.Is(terminal.ErrStopping): + err = nil + log.Debugf("spn/docks: %T %s is being abandoned by peer", t, t.FmtID()) + case err.Is(terminal.ErrNoActivity): + err = nil + log.Debugf("spn/docks: %T %s is being abandoned due to no activity", t, t.FmtID()) + default: + log.Warningf("spn/docks: %T %s: %s", t, t.FmtID(), err) + } + + // Call the terminal's abandon function. + t.Abandon(err) + } else { //nolint:gocritic + // When a crane terminal is abandoned, it calls crane.AbandonTerminal when + // finished. This time, the terminal won't be in the registry anymore and + // it finished shutting down, so we can now check if the crane needs to be + // stopped. + + // If the crane is stopping, check if we can stop. + // We can stop when all terminals are abandoned or after a timeout. + // FYI: The crane controller will always take up one slot. + if crane.stopping.IsSet() && + crane.terminalCount() <= 1 { + // Stop the crane in worker, so the caller can do some work. + module.StartWorker("retire crane", func(_ context.Context) error { + // Let enough time for the last errors to be sent, as terminals are abandoned in a goroutine. + time.Sleep(3 * time.Second) + crane.Stop(nil) + return nil + }) + } + } +} + +func (crane *Crane) sendImportantTerminalMsg(msg *terminal.Msg, timeout time.Duration) *terminal.Error { + select { + case crane.controllerMsgs <- msg: + return nil + case <-crane.ctx.Done(): + msg.Finish() + return terminal.ErrCanceled + } +} + +// Send is used by others to send a message through the crane. +func (crane *Crane) Send(msg *terminal.Msg, timeout time.Duration) *terminal.Error { + select { + case crane.terminalMsgs <- msg: + return nil + case <-crane.ctx.Done(): + msg.Finish() + return terminal.ErrCanceled + } +} + +func (crane *Crane) encrypt(shipment *container.Container) (encrypted *container.Container, err error) { + // Skip if encryption is not enabled. + if crane.jession == nil { + return shipment, nil + } + + crane.jessionLock.Lock() + defer crane.jessionLock.Unlock() + + letter, err := crane.jession.Close(shipment.CompileData()) + if err != nil { + return nil, err + } + + encrypted, err = letter.ToWire() + if err != nil { + return nil, fmt.Errorf("failed to pack letter: %w", err) + } + + return encrypted, nil +} + +func (crane *Crane) decrypt(shipment *container.Container) (decrypted *container.Container, err error) { + // Skip if encryption is not enabled. + if crane.jession == nil { + return shipment, nil + } + + crane.jessionLock.Lock() + defer crane.jessionLock.Unlock() + + letter, err := jess.LetterFromWire(shipment) + if err != nil { + return nil, fmt.Errorf("failed to parse letter: %w", err) + } + + decryptedData, err := crane.jession.Open(letter) + if err != nil { + return nil, err + } + + return container.New(decryptedData), nil +} + +func (crane *Crane) unloader(workerCtx context.Context) error { + // Unclean shutdown safeguard. + defer crane.Stop(terminal.ErrUnknownError.With("unloader died")) + + for { + // Get first couple bytes to get the packet length. + // 2 bytes are enough to encode 65535. + // On the other hand, packets can be only 2 bytes small. + lenBuf := make([]byte, 2) + err := crane.unloadUntilFull(lenBuf) + if err != nil { + if errors.Is(err, io.EOF) { + crane.Stop(terminal.ErrStopping.With("connection closed")) + } else { + crane.Stop(terminal.ErrInternalError.With("failed to unload: %w", err)) + } + return nil + } + + // Unpack length. + containerLen, n, err := varint.Unpack64(lenBuf) + if err != nil { + crane.Stop(terminal.ErrMalformedData.With("failed to get container length: %w", err)) + return nil + } + switch { + case containerLen <= 0: + crane.Stop(terminal.ErrMalformedData.With("received empty container with length %d", containerLen)) + return nil + case containerLen > maxUnloadSize: + crane.Stop(terminal.ErrMalformedData.With("received oversized container with length %d", containerLen)) + return nil + } + + // Build shipment. + var shipmentBuf []byte + leftovers := len(lenBuf) - n + + if leftovers == int(containerLen) { + // We already have all the shipment data. + shipmentBuf = lenBuf[n:] + } else { + // Create a shipment buffer, copy leftovers and read the rest from the connection. + shipmentBuf = make([]byte, containerLen) + if leftovers > 0 { + copy(shipmentBuf, lenBuf[n:]) + } + + // Read remaining shipment. + err = crane.unloadUntilFull(shipmentBuf[leftovers:]) + if err != nil { + crane.Stop(terminal.ErrInternalError.With("failed to unload: %w", err)) + return nil + } + } + + // Submit to handler. + select { + case <-crane.ctx.Done(): + crane.Stop(nil) + return nil + case crane.unloading <- container.New(shipmentBuf): + } + } +} + +func (crane *Crane) unloadUntilFull(buf []byte) error { + var bytesRead int + for { + // Get shipment from ship. + n, err := crane.ship.UnloadTo(buf[bytesRead:]) + if err != nil { + return err + } + if n == 0 { + log.Tracef("spn/docks: %s unloaded 0 bytes", crane) + } + bytesRead += n + + // Return if buffer has been fully filled. + if bytesRead == len(buf) { + // Submit metrics. + crane.submitCraneTrafficStats(bytesRead) + crane.NetState.ReportTraffic(uint64(bytesRead), true) + + return nil + } + } +} + +func (crane *Crane) handler(workerCtx context.Context) error { + var partialShipment *container.Container + var segmentLength uint32 + + // Unclean shutdown safeguard. + defer crane.Stop(terminal.ErrUnknownError.With("handler died")) + +handling: + for { + select { + case <-crane.ctx.Done(): + crane.Stop(nil) + return nil + + case shipment := <-crane.unloading: + // log.Debugf("spn/crane %s: before decrypt: %v ... %v", crane.ID, c.CompileData()[:10], c.CompileData()[c.Length()-10:]) + + // Decrypt shipment. + shipment, err := crane.decrypt(shipment) + if err != nil { + crane.Stop(terminal.ErrIntegrity.With("failed to decrypt: %w", err)) + return nil + } + + // Process all segments/containers of the shipment. + for shipment.HoldsData() { + if partialShipment != nil { + // Continue processing partial segment. + // Append new shipment to previous partial segment. + partialShipment.AppendContainer(shipment) + shipment, partialShipment = partialShipment, nil + } + + // Get next segment length. + if segmentLength == 0 { + segmentLength, err = shipment.GetNextN32() + if err != nil { + if errors.Is(err, varint.ErrBufTooSmall) { + // Continue handling when there is not yet enough data. + partialShipment = shipment + segmentLength = 0 + continue handling + } + + crane.Stop(terminal.ErrMalformedData.With("failed to get segment length: %w", err)) + return nil + } + + if segmentLength == 0 { + // Remainder is padding. + continue handling + } + + // Check if the segment is within the boundary. + if segmentLength > maxSegmentLength { + crane.Stop(terminal.ErrMalformedData.With("received oversized segment with length %d", segmentLength)) + return nil + } + } + + // Check if we have enough data for the segment. + if uint32(shipment.Length()) < segmentLength { + partialShipment = shipment + continue handling + } + + // Get segment from shipment. + segment, err := shipment.GetAsContainer(int(segmentLength)) + if err != nil { + crane.Stop(terminal.ErrMalformedData.With("failed to get segment: %w", err)) + return nil + } + segmentLength = 0 + + // Get terminal ID and message type of segment. + terminalID, terminalMsgType, err := terminal.ParseIDType(segment) + if err != nil { + crane.Stop(terminal.ErrMalformedData.With("failed to get terminal ID and msg type: %w", err)) + return nil + } + + switch terminalMsgType { + case terminal.MsgTypeInit: + crane.establishTerminal(terminalID, segment) + + case terminal.MsgTypeData, terminal.MsgTypePriorityData: + // Get terminal and let it further handle the message. + t, ok := crane.getTerminal(terminalID) + if ok { + // Create msg and set priority. + msg := terminal.NewEmptyMsg() + msg.FlowID = terminalID + msg.Type = terminalMsgType + msg.Data = segment + if msg.Type == terminal.MsgTypePriorityData { + msg.Unit.MakeHighPriority() + } + // Deliver to terminal. + deliveryErr := t.Deliver(msg) + if deliveryErr != nil { + msg.Finish() + // This is a hot path. Start a worker for abandoning the terminal. + module.StartWorker("end terminal", func(_ context.Context) error { + crane.AbandonTerminal(t.ID(), deliveryErr.Wrap("failed to deliver data")) + return nil + }) + } + } else { + log.Tracef("spn/docks: %s received msg for unknown terminal %d", crane, terminalID) + } + + case terminal.MsgTypeStop: + // Parse error. + receivedErr, err := terminal.ParseExternalError(segment.CompileData()) + if err != nil { + log.Warningf("spn/docks: %s failed to parse abandon error: %s", crane, err) + receivedErr = terminal.ErrUnknownError.AsExternal() + } + // This is a hot path. Start a worker for abandoning the terminal. + module.StartWorker("end terminal", func(_ context.Context) error { + crane.AbandonTerminal(terminalID, receivedErr) + return nil + }) + } + } + } + } +} + +func (crane *Crane) loader(workerCtx context.Context) (err error) { + shipment := container.New() + var partialShipment *container.Container + var loadingTimer *time.Timer + + // Unclean shutdown safeguard. + defer crane.Stop(terminal.ErrUnknownError.With("loader died")) + + // Return the loading wait channel if waiting. + loadNow := func() <-chan time.Time { + if loadingTimer != nil { + return loadingTimer.C + } + return nil + } + + // Make sure any received message is finished + var msg, firstMsg *terminal.Msg + defer msg.Finish() + defer firstMsg.Finish() + + for { + // Reset first message in shipment. + firstMsg.Finish() + firstMsg = nil + + fillingShipment: + for shipment.Length() < crane.targetLoadSize { + // Gather segments until shipment is filled. + + // Prioritize messages from the controller. + select { + case msg = <-crane.controllerMsgs: + case <-crane.ctx.Done(): + crane.Stop(nil) + return nil + + default: + // Then listen for all. + select { + case msg = <-crane.controllerMsgs: + case msg = <-crane.terminalMsgs: + case <-loadNow(): + break fillingShipment + case <-crane.ctx.Done(): + crane.Stop(nil) + return nil + } + } + + // Debug unit leaks. + msg.Debug() + + // Handle new message. + if msg != nil { + // Pack msg and add to segment. + msg.Pack() + newSegment := msg.Data + + // Check if this is the first message. + // This is the only message where we wait for a slot. + if firstMsg == nil { + firstMsg = msg + firstMsg.Unit.WaitForSlot() + } else { + msg.Finish() + } + + // Check length. + if newSegment.Length() > maxSegmentLength { + log.Warningf("spn/docks: %s ignored oversized segment with length %d", crane, newSegment.Length()) + continue fillingShipment + } + + // Append to shipment. + shipment.AppendContainer(newSegment) + + // Set loading max wait timer on first segment. + if loadingTimer == nil { + loadingTimer = time.NewTimer(loadingMaxWaitDuration) + } + + } else if crane.stopped.IsSet() { + // If there is no new segment, this might have been triggered by a + // closed channel. Check if the crane is still active. + return nil + } + } + + sendingShipment: + for { + // Check if we are over the target load size and split the shipment. + if shipment.Length() > crane.targetLoadSize { + partialShipment, err = shipment.GetAsContainer(crane.targetLoadSize) + if err != nil { + crane.Stop(terminal.ErrInternalError.With("failed to split segment: %w", err)) + return nil + } + shipment, partialShipment = partialShipment, shipment + } + + // Load shipment. + err = crane.load(shipment) + if err != nil { + crane.Stop(terminal.ErrShipSunk.With("failed to load shipment: %w", err)) + return nil + } + + // Reset loading timer. + loadingTimer = nil + + // Continue loading with partial shipment, or a new one. + if partialShipment != nil { + // Continue loading with a partial previous shipment. + shipment, partialShipment = partialShipment, nil + + // If shipment is not big enough to send immediately, wait for more data. + if shipment.Length() < crane.targetLoadSize { + loadingTimer = time.NewTimer(loadingMaxWaitDuration) + break sendingShipment + } + + } else { + // Continue loading with new shipment. + shipment = container.New() + break sendingShipment + } + } + } +} + +func (crane *Crane) load(c *container.Container) error { + // Add Padding if needed. + if crane.opts.Padding > 0 { + paddingNeeded := int(crane.opts.Padding) - + ((c.Length() + varint.EncodedSize(uint64(c.Length()))) % int(crane.opts.Padding)) + // As the length changes slightly with the padding, we should avoid loading + // lengths around the varint size hops: + // - 128 + // - 16384 + // - 2097152 + // - 268435456 + + // Pad to target load size at maximum. + maxPadding := crane.targetLoadSize - c.Length() + if paddingNeeded > maxPadding { + paddingNeeded = maxPadding + } + + if paddingNeeded > 0 { + // Add padding indicator. + c.Append([]byte{0}) + paddingNeeded-- + + // Add needed padding data. + if paddingNeeded > 0 { + padding, err := rng.Bytes(paddingNeeded) + if err != nil { + log.Debugf("spn/docks: %s failed to get random padding data, using zeros instead", crane) + padding = make([]byte, paddingNeeded) + } + c.Append(padding) + } + } + } + + // Encrypt shipment. + c, err := crane.encrypt(c) + if err != nil { + return fmt.Errorf("failed to encrypt: %w", err) + } + + // Finalize data. + c.PrependLength() + readyToSend := c.CompileData() + + // Submit metrics. + crane.submitCraneTrafficStats(len(readyToSend)) + crane.NetState.ReportTraffic(uint64(len(readyToSend)), false) + + // Load onto ship. + err = crane.ship.Load(readyToSend) + if err != nil { + return fmt.Errorf("failed to load ship: %w", err) + } + + return nil +} + +// Stop stops the crane. +func (crane *Crane) Stop(err *terminal.Error) { + if !crane.stopped.SetToIf(false, true) { + return + } + + // Log error message. + if err != nil { + if err.IsOK() { + log.Infof("spn/docks: %s is done", crane) + } else { + log.Warningf("spn/docks: %s is stopping: %s", crane, err) + } + } + + // Unregister crane. + unregisterCrane(crane) + + // Stop all terminals. + for _, t := range crane.allTerms() { + t.Abandon(err) // Async! + } + + // Stop controller. + if crane.Controller != nil { + crane.Controller.Abandon(err) // Async! + } + + // Wait shortly for all terminals to finish abandoning. + waitStep := 50 * time.Millisecond + for i := time.Duration(0); i < maxCraneStopDuration; i += waitStep { + // Check if all terminals are done. + if crane.terminalCount() == 0 { + break + } + + time.Sleep(waitStep) + } + + // Close connection. + crane.ship.Sink() + + // Cancel crane context. + crane.cancelCtx() + + // Notify about change. + crane.NotifyUpdate() +} + +func (crane *Crane) allTerms() []terminal.Terminal { + crane.terminalsLock.Lock() + defer crane.terminalsLock.Unlock() + + terms := make([]terminal.Terminal, 0, len(crane.terminals)) + for _, term := range crane.terminals { + terms = append(terms, term) + } + + return terms +} + +func (crane *Crane) String() string { + remoteAddr := crane.ship.RemoteAddr() + switch { + case remoteAddr == nil: + return fmt.Sprintf("crane %s", crane.ID) + case crane.ship.IsMine(): + return fmt.Sprintf("crane %s to %s", crane.ID, crane.ship.MaskAddress(crane.ship.RemoteAddr())) + default: + return fmt.Sprintf("crane %s from %s", crane.ID, crane.ship.MaskAddress(crane.ship.RemoteAddr())) + } +} + +// Stopped returns whether the crane has stopped. +func (crane *Crane) Stopped() bool { + return crane.stopped.IsSet() +} diff --git a/spn/docks/crane_establish.go b/spn/docks/crane_establish.go new file mode 100644 index 00000000..3fa26732 --- /dev/null +++ b/spn/docks/crane_establish.go @@ -0,0 +1,81 @@ +package docks + +import ( + "context" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + defaultTerminalIdleTimeout = 15 * time.Minute + remoteTerminalIdleTimeout = 30 * time.Minute +) + +// EstablishNewTerminal establishes a new terminal with the crane. +func (crane *Crane) EstablishNewTerminal( + localTerm terminal.Terminal, + initData *container.Container, +) *terminal.Error { + // Create message. + msg := terminal.NewEmptyMsg() + msg.FlowID = localTerm.ID() + msg.Type = terminal.MsgTypeInit + msg.Data = initData + + // Register terminal with crane. + crane.setTerminal(localTerm) + + // Send message. + select { + case crane.controllerMsgs <- msg: + log.Debugf("spn/docks: %s initiated new terminal %d", crane, localTerm.ID()) + return nil + case <-crane.ctx.Done(): + crane.AbandonTerminal(localTerm.ID(), terminal.ErrStopping.With("initiation aborted")) + return terminal.ErrStopping + } +} + +func (crane *Crane) establishTerminal(id uint32, initData *container.Container) { + // Create new remote crane terminal. + newTerminal, _, err := NewRemoteCraneTerminal( + crane, + id, + initData, + ) + if err == nil { + // Connections via public cranes have a timeout. + if crane.Public() { + newTerminal.TerminalBase.SetTimeout(remoteTerminalIdleTimeout) + } + // Register terminal with crane. + crane.setTerminal(newTerminal) + log.Debugf("spn/docks: %s established new crane terminal %d", crane, newTerminal.ID()) + return + } + + // If something goes wrong, send an error back. + log.Warningf("spn/docks: %s failed to establish crane terminal: %s", crane, err) + + // Build abandon message. + msg := terminal.NewMsg(err.Pack()) + msg.FlowID = id + msg.Type = terminal.MsgTypeStop + + // Send message directly, or async. + select { + case crane.terminalMsgs <- msg: + default: + // Send error async. + module.StartWorker("abandon terminal", func(ctx context.Context) error { + select { + case crane.terminalMsgs <- msg: + case <-ctx.Done(): + } + return nil + }) + } +} diff --git a/spn/docks/crane_init.go b/spn/docks/crane_init.go new file mode 100644 index 00000000..740f7cdb --- /dev/null +++ b/spn/docks/crane_init.go @@ -0,0 +1,339 @@ +package docks + +import ( + "context" + "time" + + "github.com/safing/jess" + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/info" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/terminal" +) + +/* + +Crane Init Message Format: +used by init procedures + +- Data [bytes block] + - MsgType [varint] + - Data [bytes; only when MsgType is Verify or Start*] + +Crane Init Response Format: + +- Data [bytes block] + +Crane Operational Message Format: + +- Data [bytes block] + - possibly encrypted + +*/ + +// Crane Msg Types. +const ( + CraneMsgTypeEnd = 0 + CraneMsgTypeInfo = 1 + CraneMsgTypeRequestHubInfo = 2 + CraneMsgTypeVerify = 3 + CraneMsgTypeStartEncrypted = 4 + CraneMsgTypeStartUnencrypted = 5 +) + +// Start starts the crane. +func (crane *Crane) Start(callerCtx context.Context) error { + log.Infof("spn/docks: %s is starting", crane) + + // Submit metrics. + newCranes.Inc() + + // Start crane depending on situation. + var tErr *terminal.Error + if crane.ship.IsMine() { + tErr = crane.startLocal(callerCtx) + } else { + tErr = crane.startRemote(callerCtx) + } + + // Stop crane again if starting failed. + if tErr != nil { + crane.Stop(tErr) + return tErr + } + + log.Debugf("spn/docks: %s started", crane) + // Return an explicit nil for working "!= nil" checks. + return nil +} + +func (crane *Crane) startLocal(callerCtx context.Context) *terminal.Error { + module.StartWorker("crane unloader", crane.unloader) + + if !crane.ship.IsSecure() { + // Start encrypted channel. + // Check if we have all the data we need from the Hub. + if crane.ConnectedHub == nil { + return terminal.ErrIncorrectUsage.With("cannot start encrypted channel without connected hub") + } + + // Always request hub info, as we don't know if the hub has restarted in + // the meantime and lost ephemeral keys. + hubInfoRequest := container.New( + varint.Pack8(CraneMsgTypeRequestHubInfo), + ) + hubInfoRequest.PrependLength() + err := crane.ship.Load(hubInfoRequest.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to request hub info: %w", err) + } + + // Wait for reply. + var reply *container.Container + select { + case reply = <-crane.unloading: + case <-time.After(30 * time.Second): + return terminal.ErrTimeout.With("waiting for hub info") + case <-crane.ctx.Done(): + return terminal.ErrShipSunk.With("waiting for hub info") + case <-callerCtx.Done(): + return terminal.ErrCanceled.With("waiting for hub info") + } + + // Parse and import Announcement and Status. + announcementData, err := reply.GetNextBlock() + if err != nil { + return terminal.ErrMalformedData.With("failed to get announcement: %w", err) + } + statusData, err := reply.GetNextBlock() + if err != nil { + return terminal.ErrMalformedData.With("failed to get status: %w", err) + } + h, _, tErr := ImportAndVerifyHubInfo( + callerCtx, + crane.ConnectedHub.ID, + announcementData, statusData, conf.MainMapName, conf.MainMapScope, + ) + if tErr != nil { + return tErr.Wrap("failed to import and verify hub") + } + // Update reference in case it was changed by the import. + crane.ConnectedHub = h + + // Now, try to select a public key again. + signet := crane.ConnectedHub.SelectSignet() + if signet == nil { + return terminal.ErrHubNotReady.With("failed to select signet (after updating hub info)") + } + + // Configure encryption. + env := jess.NewUnconfiguredEnvelope() + env.SuiteID = jess.SuiteWireV1 + env.Recipients = []*jess.Signet{signet} + + // Do not encrypt directly, rather get session for future use, then encrypt. + crane.jession, err = env.WireCorrespondence(nil) + if err != nil { + return terminal.ErrInternalError.With("failed to create encryption session: %w", err) + } + } + + // Create crane controller. + _, initData, tErr := NewLocalCraneControllerTerminal(crane, terminal.DefaultCraneControllerOpts()) + if tErr != nil { + return tErr.Wrap("failed to set up controller") + } + + // Prepare init message for sending. + if crane.ship.IsSecure() { + initData.PrependNumber(CraneMsgTypeStartUnencrypted) + } else { + // Encrypt controller initializer. + letter, err := crane.jession.Close(initData.CompileData()) + if err != nil { + return terminal.ErrInternalError.With("failed to encrypt initial packet: %w", err) + } + initData, err = letter.ToWire() + if err != nil { + return terminal.ErrInternalError.With("failed to pack initial packet: %w", err) + } + initData.PrependNumber(CraneMsgTypeStartEncrypted) + } + + // Send start message. + initData.PrependLength() + err := crane.ship.Load(initData.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to send init msg: %w", err) + } + + // Start remaining workers. + module.StartWorker("crane loader", crane.loader) + module.StartWorker("crane handler", crane.handler) + + return nil +} + +func (crane *Crane) startRemote(callerCtx context.Context) *terminal.Error { + var initMsg *container.Container + + module.StartWorker("crane unloader", crane.unloader) + +handling: + for { + // Wait for request. + var request *container.Container + select { + case request = <-crane.unloading: + + case <-time.After(30 * time.Second): + return terminal.ErrTimeout.With("waiting for crane init msg") + case <-crane.ctx.Done(): + return terminal.ErrShipSunk.With("waiting for crane init msg") + case <-callerCtx.Done(): + return terminal.ErrCanceled.With("waiting for crane init msg") + } + + msgType, err := request.GetNextN8() + if err != nil { + return terminal.ErrMalformedData.With("failed to parse crane msg type: %s", err) + } + + switch msgType { + case CraneMsgTypeEnd: + // End connection. + return terminal.ErrStopping + + case CraneMsgTypeInfo: + // Info is a terminating request. + err := crane.handleCraneInfo() + if err != nil { + return err + } + log.Debugf("spn/docks: %s sent version info", crane) + + case CraneMsgTypeRequestHubInfo: + // Handle Hub info request. + err := crane.handleCraneHubInfo() + if err != nil { + return err + } + log.Debugf("spn/docks: %s sent hub info", crane) + + case CraneMsgTypeVerify: + // Verify is a terminating request. + err := crane.handleCraneVerification(request) + if err != nil { + return err + } + log.Infof("spn/docks: %s sent hub verification", crane) + + case CraneMsgTypeStartUnencrypted: + initMsg = request + + // Start crane with initMsg. + log.Debugf("spn/docks: %s initiated unencrypted channel", crane) + break handling + + case CraneMsgTypeStartEncrypted: + if crane.identity == nil { + return terminal.ErrIncorrectUsage.With("cannot start incoming crane without designated identity") + } + + // Set up encryption. + letter, err := jess.LetterFromWire(container.New(request.CompileData())) + if err != nil { + return terminal.ErrMalformedData.With("failed to unpack initial packet: %w", err) + } + crane.jession, err = letter.WireCorrespondence(crane.identity) + if err != nil { + return terminal.ErrInternalError.With("failed to create encryption session: %w", err) + } + initMsgData, err := crane.jession.Open(letter) + if err != nil { + return terminal.ErrIntegrity.With("failed to decrypt initial packet: %w", err) + } + initMsg = container.New(initMsgData) + + // Start crane with initMsg. + log.Debugf("spn/docks: %s initiated encrypted channel", crane) + break handling + } + } + + _, _, err := NewRemoteCraneControllerTerminal(crane, initMsg) + if err != nil { + return err.Wrap("failed to start crane controller") + } + + // Start remaining workers. + module.StartWorker("crane loader", crane.loader) + module.StartWorker("crane handler", crane.handler) + + return nil +} + +func (crane *Crane) endInit() *terminal.Error { + endMsg := container.New( + varint.Pack8(CraneMsgTypeEnd), + ) + endMsg.PrependLength() + err := crane.ship.Load(endMsg.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to send end msg: %w", err) + } + return nil +} + +func (crane *Crane) handleCraneInfo() *terminal.Error { + // Pack info data. + infoData, err := dsd.Dump(info.GetInfo(), dsd.JSON) + if err != nil { + return terminal.ErrInternalError.With("failed to pack info: %w", err) + } + msg := container.New(infoData) + + // Manually send reply. + msg.PrependLength() + err = crane.ship.Load(msg.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to send info reply: %w", err) + } + + return nil +} + +func (crane *Crane) handleCraneHubInfo() *terminal.Error { + msg := container.New() + + // Check if we have an identity. + if crane.identity == nil { + return terminal.ErrIncorrectUsage.With("cannot handle hub info request without designated identity") + } + + // Add Hub Announcement. + announcementData, err := crane.identity.ExportAnnouncement() + if err != nil { + return terminal.ErrInternalError.With("failed to export announcement: %w", err) + } + msg.AppendAsBlock(announcementData) + + // Add Hub Status. + statusData, err := crane.identity.ExportStatus() + if err != nil { + return terminal.ErrInternalError.With("failed to export status: %w", err) + } + msg.AppendAsBlock(statusData) + + // Manually send reply. + msg.PrependLength() + err = crane.ship.Load(msg.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to send hub info reply: %w", err) + } + + return nil +} diff --git a/spn/docks/crane_netstate.go b/spn/docks/crane_netstate.go new file mode 100644 index 00000000..508f5632 --- /dev/null +++ b/spn/docks/crane_netstate.go @@ -0,0 +1,131 @@ +package docks + +import ( + "sync" + "sync/atomic" + "time" +) + +// NetStatePeriodInterval defines the interval some of the net state should be reset. +const NetStatePeriodInterval = 15 * time.Minute + +// NetworkOptimizationState holds data for optimization purposes. +type NetworkOptimizationState struct { + lock sync.Mutex + + // lastSuggestedAt holds the time when the connection to the connected Hub was last suggested by the network optimization. + lastSuggestedAt time.Time + + // stoppingRequested signifies whether stopping this lane is requested. + stoppingRequested bool + // stoppingRequestedByPeer signifies whether stopping this lane is requested by the peer. + stoppingRequestedByPeer bool + // markedStoppingAt holds the time when the crane was last marked as stopping. + markedStoppingAt time.Time + + lifetimeBytesIn *uint64 + lifetimeBytesOut *uint64 + lifetimeStarted time.Time + periodBytesIn *uint64 + periodBytesOut *uint64 + periodStarted time.Time +} + +func newNetworkOptimizationState() *NetworkOptimizationState { + return &NetworkOptimizationState{ + lifetimeBytesIn: new(uint64), + lifetimeBytesOut: new(uint64), + lifetimeStarted: time.Now(), + periodBytesIn: new(uint64), + periodBytesOut: new(uint64), + periodStarted: time.Now(), + } +} + +// UpdateLastSuggestedAt sets when the lane was last suggested to the current time. +func (netState *NetworkOptimizationState) UpdateLastSuggestedAt() { + netState.lock.Lock() + defer netState.lock.Unlock() + + netState.lastSuggestedAt = time.Now() +} + +// StoppingState returns when the stopping state. +func (netState *NetworkOptimizationState) StoppingState() (requested, requestedByPeer bool, markedAt time.Time) { + netState.lock.Lock() + defer netState.lock.Unlock() + + return netState.stoppingRequested, netState.stoppingRequestedByPeer, netState.markedStoppingAt +} + +// RequestStoppingSuggested returns whether the crane should request stopping. +func (netState *NetworkOptimizationState) RequestStoppingSuggested(maxNotSuggestedDuration time.Duration) bool { + netState.lock.Lock() + defer netState.lock.Unlock() + + return time.Now().Add(-maxNotSuggestedDuration).After(netState.lastSuggestedAt) +} + +// StoppingSuggested returns whether the crane should be marked as stopping. +func (netState *NetworkOptimizationState) StoppingSuggested() bool { + netState.lock.Lock() + defer netState.lock.Unlock() + + return netState.stoppingRequested && + netState.stoppingRequestedByPeer +} + +// StopSuggested returns whether the crane should be stopped. +func (netState *NetworkOptimizationState) StopSuggested() bool { + netState.lock.Lock() + defer netState.lock.Unlock() + + return netState.stoppingRequested && + netState.stoppingRequestedByPeer && + !netState.markedStoppingAt.IsZero() && + time.Now().Add(-maxCraneStoppingDuration).After(netState.markedStoppingAt) +} + +// ReportTraffic adds the reported transferred data to the traffic stats. +func (netState *NetworkOptimizationState) ReportTraffic(bytes uint64, in bool) { + if in { + atomic.AddUint64(netState.lifetimeBytesIn, bytes) + atomic.AddUint64(netState.periodBytesIn, bytes) + } else { + atomic.AddUint64(netState.lifetimeBytesOut, bytes) + atomic.AddUint64(netState.periodBytesOut, bytes) + } +} + +// LapsePeriod lapses the net state period, if needed. +func (netState *NetworkOptimizationState) LapsePeriod() { + netState.lock.Lock() + defer netState.lock.Unlock() + + // Reset period if interval elapsed. + if time.Now().Add(-NetStatePeriodInterval).After(netState.periodStarted) { + atomic.StoreUint64(netState.periodBytesIn, 0) + atomic.StoreUint64(netState.periodBytesOut, 0) + netState.periodStarted = time.Now() + } +} + +// GetTrafficStats returns the traffic stats. +func (netState *NetworkOptimizationState) GetTrafficStats() ( + lifetimeBytesIn uint64, + lifetimeBytesOut uint64, + lifetimeStarted time.Time, + periodBytesIn uint64, + periodBytesOut uint64, + periodStarted time.Time, +) { + netState.lock.Lock() + defer netState.lock.Unlock() + + return atomic.LoadUint64(netState.lifetimeBytesIn), + atomic.LoadUint64(netState.lifetimeBytesOut), + netState.lifetimeStarted, + atomic.LoadUint64(netState.periodBytesIn), + atomic.LoadUint64(netState.periodBytesOut), + netState.periodStarted +} diff --git a/spn/docks/crane_terminal.go b/spn/docks/crane_terminal.go new file mode 100644 index 00000000..731bf953 --- /dev/null +++ b/spn/docks/crane_terminal.go @@ -0,0 +1,122 @@ +package docks + +import ( + "net" + + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/terminal" +) + +// CraneTerminal is a terminal started by a crane. +type CraneTerminal struct { + *terminal.TerminalBase + + // Add-Ons + terminal.SessionAddOn + + crane *Crane +} + +// NewLocalCraneTerminal returns a new local crane terminal. +func NewLocalCraneTerminal( + crane *Crane, + remoteHub *hub.Hub, + initMsg *terminal.TerminalOpts, +) (*CraneTerminal, *container.Container, *terminal.Error) { + // Create Terminal Base. + t, initData, err := terminal.NewLocalBaseTerminal( + crane.ctx, + crane.getNextTerminalID(), + crane.ID, + remoteHub, + initMsg, + crane, + ) + if err != nil { + return nil, nil, err + } + + return initCraneTerminal(crane, t), initData, nil +} + +// NewRemoteCraneTerminal returns a new remote crane terminal. +func NewRemoteCraneTerminal( + crane *Crane, + id uint32, + initData *container.Container, +) (*CraneTerminal, *terminal.TerminalOpts, *terminal.Error) { + // Create Terminal Base. + t, initMsg, err := terminal.NewRemoteBaseTerminal( + crane.ctx, + id, + crane.ID, + crane.identity, + initData, + crane, + ) + if err != nil { + return nil, nil, err + } + + return initCraneTerminal(crane, t), initMsg, nil +} + +func initCraneTerminal( + crane *Crane, + t *terminal.TerminalBase, +) *CraneTerminal { + // Create Crane Terminal and assign it as the extended Terminal. + ct := &CraneTerminal{ + TerminalBase: t, + crane: crane, + } + t.SetTerminalExtension(ct) + + // Start workers. + t.StartWorkers(module, "crane terminal") + + return ct +} + +// GrantPermission grants the given permissions. +// Additionally, it will mark the crane as authenticated, if not public. +func (t *CraneTerminal) GrantPermission(grant terminal.Permission) { + // Forward granted permission to base terminal. + t.TerminalBase.GrantPermission(grant) + + // Mark crane as authenticated if not public or already authenticated. + if !t.crane.Public() && !t.crane.Authenticated() { + t.crane.authenticated.Set() + + // Submit metrics. + newAuthenticatedCranes.Inc() + } +} + +// LocalAddr returns the crane's local address. +func (t *CraneTerminal) LocalAddr() net.Addr { + return t.crane.LocalAddr() +} + +// RemoteAddr returns the crane's remote address. +func (t *CraneTerminal) RemoteAddr() net.Addr { + return t.crane.RemoteAddr() +} + +// Transport returns the crane's transport. +func (t *CraneTerminal) Transport() *hub.Transport { + return t.crane.Transport() +} + +// IsBeingAbandoned returns whether the terminal is being abandoned. +func (t *CraneTerminal) IsBeingAbandoned() bool { + return t.Abandoning.IsSet() +} + +// HandleDestruction gives the terminal the ability to clean up. +// The terminal has already fully shut down at this point. +// Should never be called directly. Call Abandon() instead. +func (t *CraneTerminal) HandleDestruction(err *terminal.Error) { + t.crane.AbandonTerminal(t.ID(), err) +} diff --git a/spn/docks/crane_test.go b/spn/docks/crane_test.go new file mode 100644 index 00000000..9e13b5e1 --- /dev/null +++ b/spn/docks/crane_test.go @@ -0,0 +1,267 @@ +package docks + +import ( + "context" + "fmt" + "os" + "runtime/pprof" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" + "github.com/safing/portmaster/spn/terminal" +) + +func TestCraneCommunication(t *testing.T) { + t.Parallel() + + testCraneWithCounter(t, "plain-counter-load-100", false, 100, 1000) + testCraneWithCounter(t, "plain-counter-load-1000", false, 1000, 1000) + testCraneWithCounter(t, "plain-counter-load-10000", false, 10000, 1000) + testCraneWithCounter(t, "encrypted-counter", true, 1000, 1000) +} + +func testCraneWithCounter(t *testing.T, testID string, encrypting bool, loadSize int, countTo uint64) { //nolint:unparam,thelper + var identity *cabin.Identity + var connectedHub *hub.Hub + if encrypting { + identity, connectedHub = getTestIdentity(t) + } + + // Build ship and cranes. + optimalMinLoadSize = loadSize * 2 + ship := ships.NewTestShip(!encrypting, loadSize) + + var crane1, crane2 *Crane + var craneWg sync.WaitGroup + craneWg.Add(2) + + go func() { + var err error + crane1, err = NewCrane(ship, connectedHub, nil) + if err != nil { + panic(fmt.Sprintf("crane test %s could not create crane1: %s", testID, err)) + } + err = crane1.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("crane test %s could not start crane1: %s", testID, err)) + } + craneWg.Done() + }() + go func() { + var err error + crane2, err = NewCrane(ship.Reverse(), nil, identity) + if err != nil { + panic(fmt.Sprintf("crane test %s could not create crane2: %s", testID, err)) + } + err = crane2.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("crane test %s could not start crane2: %s", testID, err)) + } + craneWg.Done() + }() + + craneWg.Wait() + t.Logf("crane test %s setup complete", testID) + + // Wait async for test to complete, print stack after timeout. + finished := make(chan struct{}) + go func() { + select { + case <-finished: + case <-time.After(10 * time.Second): + t.Logf("crane test %s is taking too long, print stack:", testID) + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) + os.Exit(1) + } + }() + + t.Logf("crane1 controller: %+v", crane1.Controller) + t.Logf("crane2 controller: %+v", crane2.Controller) + + // Start counters for testing. + op1, tErr := terminal.NewCounterOp(crane1.Controller, terminal.CounterOpts{ + ClientCountTo: countTo, + ServerCountTo: countTo, + }) + if tErr != nil { + t.Fatalf("crane test %s failed to run counter op: %s", testID, tErr) + } + + // Wait for completion. + op1.Wait() + close(finished) + + // Wait a little so that all errors can be propagated, so we can truly see + // if we succeeded. + time.Sleep(1 * time.Second) + + // Check errors. + if op1.Error != nil { + t.Fatalf("crane test %s counter op1 failed: %s", testID, op1.Error) + } +} + +type StreamingTerminal struct { + terminal.BareTerminal + + test *testing.T + id uint32 + crane *Crane + recv chan *terminal.Msg + testData []byte +} + +func (t *StreamingTerminal) ID() uint32 { + return t.id +} + +func (t *StreamingTerminal) Ctx() context.Context { + return module.Ctx +} + +func (t *StreamingTerminal) Deliver(msg *terminal.Msg) *terminal.Error { + t.recv <- msg + msg.Finish() + return nil +} + +func (t *StreamingTerminal) Abandon(err *terminal.Error) { + t.crane.AbandonTerminal(t.ID(), err) + if err != nil { + t.test.Errorf("streaming terminal %d failed: %s", t.id, err) + } +} + +func (t *StreamingTerminal) FmtID() string { + return fmt.Sprintf("test-%d", t.id) +} + +func TestCraneLoadingUnloading(t *testing.T) { + t.Parallel() + + testCraneWithStreaming(t, "plain-streaming", false, 100) + testCraneWithStreaming(t, "encrypted-streaming", true, 100) +} + +func testCraneWithStreaming(t *testing.T, testID string, encrypting bool, loadSize int) { //nolint:thelper + var identity *cabin.Identity + var connectedHub *hub.Hub + if encrypting { + identity, connectedHub = getTestIdentity(t) + } + + // Build ship and cranes. + optimalMinLoadSize = loadSize * 2 + ship := ships.NewTestShip(!encrypting, loadSize) + + var crane1, crane2 *Crane + var craneWg sync.WaitGroup + craneWg.Add(2) + + go func() { + var err error + crane1, err = NewCrane(ship, connectedHub, nil) + if err != nil { + panic(fmt.Sprintf("crane test %s could not create crane1: %s", testID, err)) + } + err = crane1.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("crane test %s could not start crane1: %s", testID, err)) + } + craneWg.Done() + }() + go func() { + var err error + crane2, err = NewCrane(ship.Reverse(), nil, identity) + if err != nil { + panic(fmt.Sprintf("crane test %s could not create crane2: %s", testID, err)) + } + err = crane2.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("crane test %s could not start crane2: %s", testID, err)) + } + craneWg.Done() + }() + + craneWg.Wait() + t.Logf("crane test %s setup complete", testID) + + // Wait async for test to complete, print stack after timeout. + finished := make(chan struct{}) + go func() { + select { + case <-finished: + case <-time.After(10 * time.Second): + t.Logf("crane test %s is taking too long, print stack:", testID) + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) + os.Exit(1) + } + }() + + t.Logf("crane1 controller: %+v", crane1.Controller) + t.Logf("crane2 controller: %+v", crane2.Controller) + + // Create terminals and run test. + st := &StreamingTerminal{ + test: t, + id: 8, + crane: crane2, + recv: make(chan *terminal.Msg), + testData: []byte("The quick brown fox jumps over the lazy dog."), + } + crane2.terminals[st.ID()] = st + + // Run streaming test. + var streamingWg sync.WaitGroup + streamingWg.Add(2) + count := 10000 + go func() { + for i := 1; i <= count; i++ { + msg := terminal.NewMsg(st.testData) + msg.FlowID = st.id + err := crane1.Send(msg, 1*time.Second) + if err != nil { + msg.Finish() + crane1.Stop(err.Wrap("failed to submit terminal msg")) + } + // log.Tracef("spn/testing: + %d", i) + } + t.Logf("crane test %s done with sending", testID) + streamingWg.Done() + }() + go func() { + for i := 1; i <= count; i++ { + msg := <-st.recv + assert.Equal(t, st.testData, msg.Data.CompileData(), "data mismatched") + // log.Tracef("spn/testing: - %d", i) + } + t.Logf("crane test %s done with receiving", testID) + streamingWg.Done() + }() + + // Wait for completion. + streamingWg.Wait() + close(finished) +} + +var testIdentity *cabin.Identity + +func getTestIdentity(t *testing.T) (*cabin.Identity, *hub.Hub) { + t.Helper() + + if testIdentity == nil { + var err error + testIdentity, err = cabin.CreateIdentity(module.Ctx, "test") + if err != nil { + t.Fatalf("failed to create identity: %s", err) + } + } + + return testIdentity, testIdentity.Hub +} diff --git a/spn/docks/crane_verify.go b/spn/docks/crane_verify.go new file mode 100644 index 00000000..1f4e686d --- /dev/null +++ b/spn/docks/crane_verify.go @@ -0,0 +1,85 @@ +package docks + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + hubVerificationPurpose = "hub identify verification" +) + +// VerifyConnectedHub verifies the connected Hub. +func (crane *Crane) VerifyConnectedHub(callerCtx context.Context) error { + if !crane.ship.IsMine() || crane.nextTerminalID != 0 || crane.Public() { + return errors.New("hub verification can only be executed in init phase by the client") + } + + // Create verification request. + v, request, err := cabin.CreateVerificationRequest(hubVerificationPurpose, "", "") + if err != nil { + return fmt.Errorf("failed to create verification request: %w", err) + } + + // Send it. + msg := container.New( + varint.Pack8(CraneMsgTypeVerify), + request, + ) + msg.PrependLength() + err = crane.ship.Load(msg.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to send verification request: %w", err) + } + + // Wait for reply. + var reply *container.Container + select { + case reply = <-crane.unloading: + case <-time.After(2 * time.Minute): + // Use a big timeout here, as this might keep servers from joining the + // network at all, as every servers needs to verify every server, no + // matter how far away. + return terminal.ErrTimeout.With("waiting for verification reply") + case <-crane.ctx.Done(): + return terminal.ErrShipSunk.With("waiting for verification reply") + case <-callerCtx.Done(): + return terminal.ErrShipSunk.With("waiting for verification reply") + } + + // Verify reply. + return v.Verify(reply.CompileData(), crane.ConnectedHub) +} + +func (crane *Crane) handleCraneVerification(request *container.Container) *terminal.Error { + // Check if we have an identity. + if crane.identity == nil { + return terminal.ErrIncorrectUsage.With("cannot handle verification request without designated identity") + } + + response, err := crane.identity.SignVerificationRequest( + request.CompileData(), + hubVerificationPurpose, + "", "", + ) + if err != nil { + return terminal.ErrInternalError.With("failed to sign verification request: %w", err) + } + msg := container.New(response) + + // Manually send reply. + msg.PrependLength() + err = crane.ship.Load(msg.CompileData()) + if err != nil { + return terminal.ErrShipSunk.With("failed to send verification reply: %w", err) + } + + return nil +} diff --git a/spn/docks/cranehooks.go b/spn/docks/cranehooks.go new file mode 100644 index 00000000..0355a4f7 --- /dev/null +++ b/spn/docks/cranehooks.go @@ -0,0 +1,46 @@ +package docks + +import ( + "sync" + + "github.com/safing/portbase/log" +) + +var ( + craneUpdateHook func(crane *Crane) + craneUpdateHookLock sync.Mutex +) + +// RegisterCraneUpdateHook allows the captain to hook into receiving updates for cranes. +func RegisterCraneUpdateHook(fn func(crane *Crane)) { + craneUpdateHookLock.Lock() + defer craneUpdateHookLock.Unlock() + + if craneUpdateHook == nil { + craneUpdateHook = fn + } else { + log.Error("spn/docks: crane update hook already registered") + } +} + +// ResetCraneUpdateHook resets the hook for receiving updates for cranes. +func ResetCraneUpdateHook() { + craneUpdateHookLock.Lock() + defer craneUpdateHookLock.Unlock() + + craneUpdateHook = nil +} + +// NotifyUpdate calls the registers crane update hook function. +func (crane *Crane) NotifyUpdate() { + if crane == nil { + return + } + + craneUpdateHookLock.Lock() + defer craneUpdateHookLock.Unlock() + + if craneUpdateHook != nil { + craneUpdateHook(crane) + } +} diff --git a/spn/docks/hub_import.go b/spn/docks/hub_import.go new file mode 100644 index 00000000..377164f2 --- /dev/null +++ b/spn/docks/hub_import.go @@ -0,0 +1,189 @@ +package docks + +import ( + "context" + "fmt" + "net" + "sync" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" + "github.com/safing/portmaster/spn/terminal" +) + +var hubImportLock sync.Mutex + +// ImportAndVerifyHubInfo imports the given hub message and verifies them. +func ImportAndVerifyHubInfo(ctx context.Context, hubID string, announcementData, statusData []byte, mapName string, scope hub.Scope) (h *hub.Hub, forward bool, tErr *terminal.Error) { + var firstErr *terminal.Error + + // Synchronize import, as we might easily learn of a new hub from different + // gossip channels simultaneously. + hubImportLock.Lock() + defer hubImportLock.Unlock() + + // Check arguments. + if announcementData == nil && statusData == nil { + return nil, false, terminal.ErrInternalError.With("no announcement or status supplied") + } + + // Import Announcement, if given. + var hubKnown, hubChanged bool + if announcementData != nil { + hubFromMsg, known, changed, err := hub.ApplyAnnouncement(nil, announcementData, mapName, scope, false) + if err != nil && firstErr == nil { + firstErr = terminal.ErrInternalError.With("failed to apply announcement: %w", err) + } + if known { + hubKnown = true + } + if changed { + hubChanged = true + } + if hubFromMsg != nil { + h = hubFromMsg + } + } + + // Import Status, if given. + if statusData != nil { + hubFromMsg, known, changed, err := hub.ApplyStatus(h, statusData, mapName, scope, false) + if err != nil && firstErr == nil { + firstErr = terminal.ErrInternalError.With("failed to apply status: %w", err) + } + if known && announcementData == nil { + // If we parsed an announcement before, "known" will always be true here, + // as we supply hub.ApplyStatus with a hub. + hubKnown = true + } + if changed { + hubChanged = true + } + if hubFromMsg != nil { + h = hubFromMsg + } + } + + // Only continue if we now have a Hub. + if h == nil { + if firstErr != nil { + return nil, false, firstErr + } + return nil, false, terminal.ErrInternalError.With("got not hub after data import") + } + + // Abort if the given hub ID does not match. + // We may have just connected to the wrong IP address. + if hubID != "" && h.ID != hubID { + return nil, false, terminal.ErrInternalError.With("hub mismatch") + } + + // Verify hub if: + // - There is no error up until here. + // - There has been any change. + // - The hub is not verified yet. + // - We're a public Hub. + // - We're not testing. + if firstErr == nil && hubChanged && !h.Verified() && conf.PublicHub() && !runningTests { + if !conf.HubHasIPv4() && !conf.HubHasIPv6() { + firstErr = terminal.ErrInternalError.With("no hub networks set") + } + if h.Info.IPv4 != nil && conf.HubHasIPv4() { + err := verifyHubIP(ctx, h, h.Info.IPv4) + if err != nil { + firstErr = terminal.ErrIntegrity.With("failed to verify IPv4 address %s of %s: %w", h.Info.IPv4, h, err) + } + } + if h.Info.IPv6 != nil && conf.HubHasIPv6() { + err := verifyHubIP(ctx, h, h.Info.IPv6) + if err != nil { + firstErr = terminal.ErrIntegrity.With("failed to verify IPv6 address %s of %s: %w", h.Info.IPv6, h, err) + } + } + + if firstErr != nil { + func() { + h.Lock() + defer h.Unlock() + h.InvalidInfo = true + }() + log.Warningf("spn/docks: failed to verify IPs of %s: %s", h, firstErr) + } else { + func() { + h.Lock() + defer h.Unlock() + h.VerifiedIPs = true + }() + log.Infof("spn/docks: verified IPs of %s: IPv4=%s IPv6=%s", h, h.Info.IPv4, h.Info.IPv6) + } + } + + // Dismiss initial imports with errors. + if !hubKnown && firstErr != nil { + return nil, false, firstErr + } + + // Don't do anything if nothing changed. + if !hubChanged { + return h, false, firstErr + } + + // We now have one of: + // - A unknown Hub without error. + // - A known Hub without error. + // - A known Hub with error, which we want to save and propagate. + + // Save the Hub to the database. + err := h.Save() + if err != nil { + log.Errorf("spn/docks: failed to persist %s: %s", h, err) + } + + // Save the raw messages to the database. + if announcementData != nil { + err = hub.SaveHubMsg(h.ID, h.Map, hub.MsgTypeAnnouncement, announcementData) + if err != nil { + log.Errorf("spn/docks: failed to save raw announcement msg of %s: %s", h, err) + } + } + if statusData != nil { + err = hub.SaveHubMsg(h.ID, h.Map, hub.MsgTypeStatus, statusData) + if err != nil { + log.Errorf("spn/docks: failed to save raw status msg of %s: %s", h, err) + } + } + + return h, true, firstErr +} + +func verifyHubIP(ctx context.Context, h *hub.Hub, ip net.IP) error { + // Create connection. + ship, err := ships.Launch(ctx, h, nil, ip) + if err != nil { + return fmt.Errorf("failed to launch ship to %s: %w", ip, err) + } + + // Start crane for receiving reply. + crane, err := NewCrane(ship, h, nil) + if err != nil { + return fmt.Errorf("failed to create crane: %w", err) + } + module.StartWorker("crane unloader", crane.unloader) + defer crane.Stop(nil) + + // Verify Hub. + err = crane.VerifyConnectedHub(ctx) + if err != nil { + return err + } + + // End connection. + tErr := crane.endInit() + if tErr != nil { + log.Debugf("spn/docks: failed to end verification connection to %s: %s", ip, tErr) + } + + return nil +} diff --git a/spn/docks/measurements.go b/spn/docks/measurements.go new file mode 100644 index 00000000..ed2edfb3 --- /dev/null +++ b/spn/docks/measurements.go @@ -0,0 +1,108 @@ +package docks + +import ( + "context" + "fmt" + "time" + + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" + "github.com/safing/portmaster/spn/terminal" +) + +// Measurement Configuration. +const ( + CraneMeasurementTTLDefault = 30 * time.Minute + CraneMeasurementTTLByCostBase = 1 * time.Minute + CraneMeasurementTTLByCostMin = 30 * time.Minute + CraneMeasurementTTLByCostMax = 3 * time.Hour + + // With a base TTL of 1m, this leads to: + // 20c -> 20m -> raised to 30m + // 50c -> 50m + // 100c -> 1h40m + // 1000c -> 16h40m -> capped to 3h. +) + +// MeasureHub measures the connection to this Hub and saves the results to the +// Hub. +func MeasureHub(ctx context.Context, h *hub.Hub, checkExpiryWith time.Duration) *terminal.Error { + // Check if we are measuring before building a connection. + if capacityTestRunning.IsSet() { + return terminal.ErrTryAgainLater.With("another capacity op is already running") + } + + // Check if we have a connection to this Hub. + crane := GetAssignedCrane(h.ID) + if crane == nil { + // Connect to Hub. + var err error + crane, err = establishCraneForMeasuring(ctx, h) + if err != nil { + return terminal.ErrConnectionError.With("failed to connect to %s: %s", h, err) + } + // Stop crane if established just for measuring. + defer crane.Stop(nil) + } + + // Run latency test. + _, expires := h.GetMeasurements().GetLatency() + if checkExpiryWith == 0 || time.Now().Add(-checkExpiryWith).After(expires) { + latOp, tErr := NewLatencyTestOp(crane.Controller) + if !tErr.IsOK() { + return tErr + } + select { + case tErr = <-latOp.Result(): + if !tErr.IsOK() { + return tErr + } + case <-ctx.Done(): + return terminal.ErrCanceled + case <-time.After(1 * time.Minute): + crane.Controller.StopOperation(latOp, terminal.ErrTimeout) + return terminal.ErrTimeout.With("waiting for latency test") + } + } + + // Run capacity test. + _, expires = h.GetMeasurements().GetCapacity() + if checkExpiryWith == 0 || time.Now().Add(-checkExpiryWith).After(expires) { + capOp, tErr := NewCapacityTestOp(crane.Controller, nil) + if !tErr.IsOK() { + return tErr + } + select { + case tErr = <-capOp.Result(): + if !tErr.IsOK() { + return tErr + } + case <-ctx.Done(): + return terminal.ErrCanceled + case <-time.After(1 * time.Minute): + crane.Controller.StopOperation(capOp, terminal.ErrTimeout) + return terminal.ErrTimeout.With("waiting for capacity test") + } + } + + return nil +} + +func establishCraneForMeasuring(ctx context.Context, dst *hub.Hub) (*Crane, error) { + ship, err := ships.Launch(ctx, dst, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to launch ship: %w", err) + } + + crane, err := NewCrane(ship, dst, nil) + if err != nil { + return nil, fmt.Errorf("failed to create crane: %w", err) + } + + err = crane.Start(ctx) + if err != nil { + return nil, fmt.Errorf("failed to start crane: %w", err) + } + + return crane, nil +} diff --git a/spn/docks/metrics.go b/spn/docks/metrics.go new file mode 100644 index 00000000..49df92bd --- /dev/null +++ b/spn/docks/metrics.go @@ -0,0 +1,404 @@ +package docks + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/metrics" +) + +var ( + newCranes *metrics.Counter + newPublicCranes *metrics.Counter + newAuthenticatedCranes *metrics.Counter + + trafficBytesPublicCranes *metrics.Counter + trafficBytesAuthenticatedCranes *metrics.Counter + trafficBytesPrivateCranes *metrics.Counter + + newExpandOp *metrics.Counter + expandOpDurationHistogram *metrics.Histogram + expandOpRelayedDataHistogram *metrics.Histogram + + metricsRegistered = abool.New() +) + +func registerMetrics() (err error) { + // Only register metrics once. + if !metricsRegistered.SetToIf(false, true) { + return nil + } + + // Total Crane Stats. + + newCranes, err = metrics.NewCounter( + "spn/cranes/total", + nil, + &metrics.Options{ + Name: "SPN New Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + newPublicCranes, err = metrics.NewCounter( + "spn/cranes/public/total", + nil, + &metrics.Options{ + Name: "SPN New Public Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + newAuthenticatedCranes, err = metrics.NewCounter( + "spn/cranes/authenticated/total", + nil, + &metrics.Options{ + Name: "SPN New Authenticated Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + // Active Crane Stats. + + _, err = metrics.NewGauge( + "spn/cranes/active", + map[string]string{ + "status": "public", + }, + getActivePublicCranes, + &metrics.Options{ + Name: "SPN Active Public Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/cranes/active", + map[string]string{ + "status": "authenticated", + }, + getActiveAuthenticatedCranes, + &metrics.Options{ + Name: "SPN Active Authenticated Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/cranes/active", + map[string]string{ + "status": "private", + }, + getActivePrivateCranes, + &metrics.Options{ + Name: "SPN Active Private Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/cranes/active", + map[string]string{ + "status": "stopping", + }, + getActiveStoppingCranes, + &metrics.Options{ + Name: "SPN Active Stopping Cranes", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + // Crane Traffic Stats. + + trafficBytesPublicCranes, err = metrics.NewCounter( + "spn/cranes/bytes", + map[string]string{ + "status": "public", + }, + &metrics.Options{ + Name: "SPN Public Crane Traffic", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + trafficBytesAuthenticatedCranes, err = metrics.NewCounter( + "spn/cranes/bytes", + map[string]string{ + "status": "authenticated", + }, + &metrics.Options{ + Name: "SPN Authenticated Crane Traffic", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + trafficBytesPrivateCranes, err = metrics.NewCounter( + "spn/cranes/bytes", + map[string]string{ + "status": "private", + }, + &metrics.Options{ + Name: "SPN Private Crane Traffic", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + // Lane Stats. + + _, err = metrics.NewGauge( + "spn/lanes/latency/avg/seconds", + nil, + getAvgLaneLatencyStat, + &metrics.Options{ + Name: "SPN Avg Lane Latency", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/lanes/latency/min/seconds", + nil, + getMinLaneLatencyStat, + &metrics.Options{ + Name: "SPN Min Lane Latency", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/lanes/capacity/avg/bytes", + nil, + getAvgLaneCapacityStat, + &metrics.Options{ + Name: "SPN Avg Lane Capacity", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/lanes/capacity/max/bytes", + nil, + getMaxLaneCapacityStat, + &metrics.Options{ + Name: "SPN Max Lane Capacity", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + // Expand Op Stats. + + newExpandOp, err = metrics.NewCounter( + "spn/op/expand/total", + nil, + &metrics.Options{ + Name: "SPN Total Expand Operations", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/op/expand/active", + nil, + getActiveExpandOpsStat, + &metrics.Options{ + Name: "SPN Active Expand Operations", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + expandOpDurationHistogram, err = metrics.NewHistogram( + "spn/op/expand/histogram/duration/seconds", + nil, + &metrics.Options{ + Name: "SPN Expand Operation Duration Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + expandOpRelayedDataHistogram, err = metrics.NewHistogram( + "spn/op/expand/histogram/traffic/bytes", + nil, + &metrics.Options{ + Name: "SPN Expand Operation Relayed Data Histogram", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + return err +} + +func getActiveExpandOpsStat() float64 { + return float64(atomic.LoadInt64(activeExpandOps)) +} + +var ( + craneStats *craneGauges + craneStatsExpires time.Time + craneStatsLock sync.Mutex + craneStatsTTL = 55 * time.Second +) + +type craneGauges struct { + publicActive float64 + authenticatedActive float64 + privateActive float64 + stoppingActive float64 + + laneLatencyAvg float64 + laneLatencyMin float64 + laneCapacityAvg float64 + laneCapacityMax float64 +} + +func getActivePublicCranes() float64 { return getCraneStats().publicActive } +func getActiveAuthenticatedCranes() float64 { return getCraneStats().authenticatedActive } +func getActivePrivateCranes() float64 { return getCraneStats().privateActive } +func getActiveStoppingCranes() float64 { return getCraneStats().stoppingActive } +func getAvgLaneLatencyStat() float64 { return getCraneStats().laneLatencyAvg } +func getMinLaneLatencyStat() float64 { return getCraneStats().laneLatencyMin } +func getAvgLaneCapacityStat() float64 { return getCraneStats().laneCapacityAvg } +func getMaxLaneCapacityStat() float64 { return getCraneStats().laneCapacityMax } + +func getCraneStats() *craneGauges { + craneStatsLock.Lock() + defer craneStatsLock.Unlock() + + // Return cache if still valid. + if time.Now().Before(craneStatsExpires) { + return craneStats + } + + // Refresh. + craneStats = &craneGauges{} + var laneStatCnt float64 + for _, crane := range getAllCranes() { + switch { + case crane.Stopped(): + continue + case crane.IsStopping(): + craneStats.stoppingActive++ + continue + case crane.Public(): + craneStats.publicActive++ + case crane.Authenticated(): + craneStats.authenticatedActive++ + continue + default: + craneStats.privateActive++ + continue + } + + // Get lane stats. + if crane.ConnectedHub == nil { + continue + } + measurements := crane.ConnectedHub.GetMeasurements() + laneLatency, _ := measurements.GetLatency() + if laneLatency == 0 { + continue + } + laneCapacity, _ := measurements.GetCapacity() + if laneCapacity == 0 { + continue + } + + // Only use data if both latency and capacity is available. + laneStatCnt++ + + // Convert to base unit: seconds. + latency := laneLatency.Seconds() + // Add to avg and set min if lower. + craneStats.laneLatencyAvg += latency + if craneStats.laneLatencyMin > latency || craneStats.laneLatencyMin == 0 { + craneStats.laneLatencyMin = latency + } + + // Convert in base unit: bytes. + capacity := float64(laneCapacity) / 8 + // Add to avg and set max if higher. + craneStats.laneCapacityAvg += capacity + if craneStats.laneCapacityMax < capacity { + craneStats.laneCapacityMax = capacity + } + } + + // Create averages. + if laneStatCnt > 0 { + craneStats.laneLatencyAvg /= laneStatCnt + craneStats.laneCapacityAvg /= laneStatCnt + } + + craneStatsExpires = time.Now().Add(craneStatsTTL) + return craneStats +} + +func (crane *Crane) submitCraneTrafficStats(bytes int) { + switch { + case crane.Stopped(): + return + case crane.Public(): + trafficBytesPublicCranes.Add(bytes) + case crane.Authenticated(): + trafficBytesAuthenticatedCranes.Add(bytes) + default: + trafficBytesPrivateCranes.Add(bytes) + } +} diff --git a/spn/docks/module.go b/spn/docks/module.go new file mode 100644 index 00000000..31a4da95 --- /dev/null +++ b/spn/docks/module.go @@ -0,0 +1,117 @@ +package docks + +import ( + "encoding/hex" + "errors" + "fmt" + "sync" + + "github.com/safing/portbase/modules" + "github.com/safing/portbase/rng" + _ "github.com/safing/portmaster/spn/access" +) + +var ( + module *modules.Module + + allCranes = make(map[string]*Crane) // ID = Crane ID + assignedCranes = make(map[string]*Crane) // ID = connected Hub ID + cranesLock sync.RWMutex + + runningTests bool +) + +func init() { + module = modules.Register("docks", nil, start, stopAllCranes, "terminal", "cabin", "access") +} + +func start() error { + return registerMetrics() +} + +func registerCrane(crane *Crane) error { + cranesLock.Lock() + defer cranesLock.Unlock() + + // Generate new IDs until a unique one is found. + for i := 0; i < 100; i++ { + // Generate random ID. + randomID, err := rng.Bytes(3) + if err != nil { + return fmt.Errorf("failed to generate crane ID: %w", err) + } + newID := hex.EncodeToString(randomID) + + // Check if ID already exists. + _, ok := allCranes[newID] + if !ok { + crane.ID = newID + allCranes[crane.ID] = crane + return nil + } + } + + return errors.New("failed to find unique crane ID") +} + +func unregisterCrane(crane *Crane) { + cranesLock.Lock() + defer cranesLock.Unlock() + + delete(allCranes, crane.ID) + if crane.ConnectedHub != nil { + delete(assignedCranes, crane.ConnectedHub.ID) + } +} + +func stopAllCranes() error { + for _, crane := range getAllCranes() { + crane.Stop(nil) + } + return nil +} + +// AssignCrane assigns a crane to the given Hub ID. +func AssignCrane(hubID string, crane *Crane) { + cranesLock.Lock() + defer cranesLock.Unlock() + + assignedCranes[hubID] = crane +} + +// GetAssignedCrane returns the assigned crane of the given Hub ID. +func GetAssignedCrane(hubID string) *Crane { + cranesLock.RLock() + defer cranesLock.RUnlock() + + crane, ok := assignedCranes[hubID] + if ok { + return crane + } + return nil +} + +func getAllCranes() map[string]*Crane { + copiedCranes := make(map[string]*Crane, len(allCranes)) + + cranesLock.RLock() + defer cranesLock.RUnlock() + + for id, crane := range allCranes { + copiedCranes[id] = crane + } + return copiedCranes +} + +// GetAllAssignedCranes returns a copy of the map of all assigned cranes. +func GetAllAssignedCranes() map[string]*Crane { + copiedCranes := make(map[string]*Crane, len(assignedCranes)) + + cranesLock.RLock() + defer cranesLock.RUnlock() + + for destination, crane := range assignedCranes { + copiedCranes[destination] = crane + } + return copiedCranes +} diff --git a/spn/docks/module_test.go b/spn/docks/module_test.go new file mode 100644 index 00000000..0383cc21 --- /dev/null +++ b/spn/docks/module_test.go @@ -0,0 +1,16 @@ +package docks + +import ( + "testing" + + "github.com/safing/portmaster/service/core/pmtesting" + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/conf" +) + +func TestMain(m *testing.M) { + runningTests = true + conf.EnablePublicHub(true) // Make hub config available. + access.EnableTestMode() // Register test zone instead of real ones. + pmtesting.TestMain(m, module) +} diff --git a/spn/docks/op_capacity.go b/spn/docks/op_capacity.go new file mode 100644 index 00000000..a66ae617 --- /dev/null +++ b/spn/docks/op_capacity.go @@ -0,0 +1,356 @@ +package docks + +import ( + "bytes" + "context" + "sync/atomic" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + // CapacityTestOpType is the type ID of the capacity test operation. + CapacityTestOpType = "capacity" + + defaultCapacityTestVolume = 50000000 // 50MB + maxCapacityTestVolume = 100000000 // 100MB + + defaultCapacityTestMaxTime = 5 * time.Second + maxCapacityTestMaxTime = 15 * time.Second + capacityTestTimeout = 30 * time.Second + + capacityTestMsgSize = 1000 + capacityTestSendTimeout = 1000 * time.Millisecond +) + +var ( + capacityTestSendData = make([]byte, capacityTestMsgSize) + capacityTestDataReceivedSignal = []byte("ACK") + + capacityTestRunning = abool.New() +) + +// CapacityTestOp is used for capacity test operations. +type CapacityTestOp struct { //nolint:maligned + terminal.OperationBase + + opts *CapacityTestOptions + + started bool + startTime time.Time + senderStarted bool + + recvQueue chan *terminal.Msg + dataReceived int + dataReceivedAckWasAckd bool + + dataSent *int64 + dataSentWasAckd *abool.AtomicBool + + testResult int + result chan *terminal.Error +} + +// CapacityTestOptions holds options for the capacity test. +type CapacityTestOptions struct { + TestVolume int + MaxTime time.Duration + testing bool +} + +// Type returns the type ID. +func (op *CapacityTestOp) Type() string { + return CapacityTestOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: CapacityTestOpType, + Requires: terminal.IsCraneController, + Start: startCapacityTestOp, + }) +} + +// NewCapacityTestOp runs a capacity test. +func NewCapacityTestOp(t terminal.Terminal, opts *CapacityTestOptions) (*CapacityTestOp, *terminal.Error) { + // Check options. + if opts == nil { + opts = &CapacityTestOptions{ + TestVolume: defaultCapacityTestVolume, + MaxTime: defaultCapacityTestMaxTime, + } + } + + // Check if another test is already running. + if !opts.testing && !capacityTestRunning.SetToIf(false, true) { + return nil, terminal.ErrTryAgainLater.With("another capacity op is already running") + } + + // Create and init. + op := &CapacityTestOp{ + opts: opts, + recvQueue: make(chan *terminal.Msg), + dataSent: new(int64), + dataSentWasAckd: abool.New(), + result: make(chan *terminal.Error, 1), + } + + // Make capacity test request. + request, err := dsd.Dump(op.opts, dsd.CBOR) + if err != nil { + capacityTestRunning.UnSet() + return nil, terminal.ErrInternalError.With("failed to serialize capactity test options: %w", err) + } + + // Send test request. + tErr := t.StartOperation(op, container.New(request), 1*time.Second) + if tErr != nil { + capacityTestRunning.UnSet() + return nil, tErr + } + + // Start handler. + module.StartWorker("op capacity handler", op.handler) + + return op, nil +} + +func startCapacityTestOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Check if another test is already running. + if !capacityTestRunning.SetToIf(false, true) { + return nil, terminal.ErrTryAgainLater.With("another capacity op is already running") + } + + // Parse options. + opts := &CapacityTestOptions{} + _, err := dsd.Load(data.CompileData(), opts) + if err != nil { + capacityTestRunning.UnSet() + return nil, terminal.ErrMalformedData.With("failed to parse options: %w", err) + } + + // Check options. + if opts.TestVolume > maxCapacityTestVolume { + capacityTestRunning.UnSet() + return nil, terminal.ErrInvalidOptions.With("maximum volume exceeded") + } + if opts.MaxTime > maxCapacityTestMaxTime { + capacityTestRunning.UnSet() + return nil, terminal.ErrInvalidOptions.With("maximum maxtime exceeded") + } + + // Create operation. + op := &CapacityTestOp{ + opts: opts, + recvQueue: make(chan *terminal.Msg, 1000), + dataSent: new(int64), + dataSentWasAckd: abool.New(), + result: make(chan *terminal.Error, 1), + } + op.InitOperationBase(t, opID) + + // Start handler and sender. + op.senderStarted = true + module.StartWorker("op capacity handler", op.handler) + module.StartWorker("op capacity sender", op.sender) + + return op, nil +} + +func (op *CapacityTestOp) handler(ctx context.Context) error { + defer capacityTestRunning.UnSet() + + returnErr := terminal.ErrStopping + defer func() { + // Linters don't get that returnErr is used when directly used as defer. + op.Stop(op, returnErr) + }() + + var maxTestTimeReached <-chan time.Time + opTimeout := time.After(capacityTestTimeout) + + // Setup unit handling + var msg *terminal.Msg + defer msg.Finish() + + // Handle receives. + for { + msg.Finish() + + select { + case <-ctx.Done(): + returnErr = terminal.ErrCanceled + return nil + + case <-opTimeout: + returnErr = terminal.ErrTimeout + return nil + + case <-maxTestTimeReached: + returnErr = op.reportMeasuredCapacity() + return nil + + case msg = <-op.recvQueue: + // Record start time and start sender. + if !op.started { + op.started = true + op.startTime = time.Now() + maxTestTimeReached = time.After(op.opts.MaxTime) + if !op.senderStarted { + op.senderStarted = true + module.StartWorker("op capacity sender", op.sender) + } + } + + // Add to received data counter. + op.dataReceived += msg.Data.Length() + + // Check if we received the data received signal. + if msg.Data.Length() == len(capacityTestDataReceivedSignal) && + bytes.Equal(msg.Data.CompileData(), capacityTestDataReceivedSignal) { + op.dataSentWasAckd.Set() + } + + // Send the data received signal when we received the full test volume. + if op.dataReceived >= op.opts.TestVolume && !op.dataReceivedAckWasAckd { + tErr := op.Send(op.NewMsg(capacityTestDataReceivedSignal), capacityTestSendTimeout) + if tErr != nil { + returnErr = tErr.Wrap("failed to send data received signal") + return nil + } + atomic.AddInt64(op.dataSent, int64(len(capacityTestDataReceivedSignal))) + op.dataReceivedAckWasAckd = true + + // Flush last message. + op.Flush(10 * time.Second) + } + + // Check if we can complete the test. + if op.dataReceivedAckWasAckd && + op.dataSentWasAckd.IsSet() { + returnErr = op.reportMeasuredCapacity() + return nil + } + } + } +} + +func (op *CapacityTestOp) sender(ctx context.Context) error { + for { + // Send next chunk. + msg := op.NewMsg(capacityTestSendData) + msg.Unit.MakeHighPriority() + tErr := op.Send(msg, capacityTestSendTimeout) + if tErr != nil { + op.Stop(op, tErr.Wrap("failed to send capacity test data")) + return nil + } + + // Add to sent data counter and stop sending if sending is complete. + if atomic.AddInt64(op.dataSent, int64(len(capacityTestSendData))) >= int64(op.opts.TestVolume) { + return nil + } + + // Check if we have received an ack. + if op.dataSentWasAckd.IsSet() { + return nil + } + + // Check if op has ended. + if op.Stopped() { + return nil + } + } +} + +func (op *CapacityTestOp) reportMeasuredCapacity() *terminal.Error { + // Calculate lane capacity and set it. + timeNeeded := time.Since(op.startTime) + if timeNeeded <= 0 { + timeNeeded = 1 + } + duplexBits := float64((int64(op.dataReceived) + atomic.LoadInt64(op.dataSent)) * 8) + duplexNSBitRate := duplexBits / float64(timeNeeded) + bitRate := (duplexNSBitRate / 2) * float64(time.Second) + op.testResult = int(bitRate) + + // Save the result to the crane. + if controller, ok := op.Terminal().(*CraneControllerTerminal); ok { + if controller.Crane.ConnectedHub != nil { + controller.Crane.ConnectedHub.GetMeasurements().SetCapacity(op.testResult) + log.Infof( + "docks: measured capacity to %s: %.2f Mbit/s (%.2fMB down / %.2fMB up in %s)", + controller.Crane.ConnectedHub, + float64(op.testResult)/1000000, + float64(op.dataReceived)/1000000, + float64(atomic.LoadInt64(op.dataSent))/1000000, + timeNeeded, + ) + return nil + } else if controller.Crane.IsMine() { + return terminal.ErrInternalError.With("capacity operation was run on %s without a connected hub set", controller.Crane) + } + } else if !runningTests { + return terminal.ErrInternalError.With("capacity operation was run on terminal that is not a crane controller, but %T", op.Terminal()) + } + + return nil +} + +// Deliver delivers a message. +func (op *CapacityTestOp) Deliver(msg *terminal.Msg) *terminal.Error { + // Optimized delivery with 1s timeout. + select { + case op.recvQueue <- msg: + default: + select { + case op.recvQueue <- msg: + case <-time.After(1 * time.Second): + msg.Finish() + return terminal.ErrTimeout + } + } + return nil +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *CapacityTestOp) HandleStop(tErr *terminal.Error) (errorToSend *terminal.Error) { + // Return result to waiting routine. + select { + case op.result <- tErr: + default: + } + + // Drain the recvQueue to finish the message units. +drain: + for { + select { + case msg := <-op.recvQueue: + msg.Finish() + default: + select { + case msg := <-op.recvQueue: + msg.Finish() + case <-time.After(3 * time.Millisecond): + // Give some additional time buffer to drain the queue. + break drain + } + } + } + + // Return error as is. + return tErr +} + +// Result returns the result (end error) of the operation. +func (op *CapacityTestOp) Result() <-chan *terminal.Error { + return op.result +} diff --git a/spn/docks/op_capacity_test.go b/spn/docks/op_capacity_test.go new file mode 100644 index 00000000..1aaa1437 --- /dev/null +++ b/spn/docks/op_capacity_test.go @@ -0,0 +1,85 @@ +package docks + +import ( + "testing" + "time" + + "github.com/safing/portmaster/spn/terminal" +) + +var ( + testCapacityTestVolume = 1_000_000 + testCapacitytestMaxTime = 1 * time.Second +) + +func TestCapacityOp(t *testing.T) { //nolint:paralleltest // Performance test. + // Defaults. + testCapacityOp(t, &CapacityTestOptions{ + TestVolume: testCapacityTestVolume, + MaxTime: testCapacitytestMaxTime, + testing: true, + }) + + // Hit max time first. + testCapacityOp(t, &CapacityTestOptions{ + TestVolume: testCapacityTestVolume, + MaxTime: 100 * time.Millisecond, + testing: true, + }) + + // Hit volume first. + testCapacityOp(t, &CapacityTestOptions{ + TestVolume: 100_000, + MaxTime: testCapacitytestMaxTime, + testing: true, + }) +} + +func testCapacityOp(t *testing.T, opts *CapacityTestOptions) { + t.Helper() + + var ( + capTestDelay = 5 * time.Millisecond + capTestQueueSize uint32 = 10 + ) + + // Create test terminal pair. + a, b, err := terminal.NewSimpleTestTerminalPair( + capTestDelay, + int(capTestQueueSize), + &terminal.TerminalOpts{ + FlowControl: terminal.FlowControlDFQ, + FlowControlSize: capTestQueueSize, + }, + ) + if err != nil { + t.Fatalf("failed to create test terminal pair: %s", err) + } + + // Grant permission for op on remote terminal and start op. + b.GrantPermission(terminal.IsCraneController) + op, tErr := NewCapacityTestOp(a, opts) + if tErr != nil { + t.Fatalf("failed to start op: %s", err) + } + + // Wait for result and check error. + tErr = <-op.Result() + if !tErr.IsOK() { + t.Fatalf("op failed: %s", tErr) + } + t.Logf("measured capacity: %d bit/s", op.testResult) + + // Calculate expected bandwidth. + expectedBitsPerSecond := float64(capacityTestMsgSize*8*int64(capTestQueueSize)) / float64(capTestDelay) * float64(time.Second) + t.Logf("expected capacity: %f bit/s", expectedBitsPerSecond) + + // Check if measured bandwidth is within parameters. + if float64(op.testResult) > expectedBitsPerSecond*1.6 { + t.Fatal("measured capacity too high") + } + // TODO: Check if we can raise this to at least 90%. + if float64(op.testResult) < expectedBitsPerSecond*0.2 { + t.Fatal("measured capacity too low") + } +} diff --git a/spn/docks/op_expand.go b/spn/docks/op_expand.go new file mode 100644 index 00000000..4a96c766 --- /dev/null +++ b/spn/docks/op_expand.go @@ -0,0 +1,393 @@ +package docks + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/terminal" +) + +// ExpandOpType is the type ID of the expand operation. +const ExpandOpType string = "expand" + +var activeExpandOps = new(int64) + +// ExpandOp is used to expand to another Hub. +type ExpandOp struct { + terminal.OperationBase + opts *terminal.TerminalOpts + + // ctx is the context of the Terminal. + ctx context.Context + // cancelCtx cancels ctx. + cancelCtx context.CancelFunc + + dataRelayed *uint64 + ended *abool.AtomicBool + + relayTerminal *ExpansionRelayTerminal + + // flowControl holds the flow control system. + flowControl terminal.FlowControl + // deliverProxy is populated with the configured deliver function + deliverProxy func(msg *terminal.Msg) *terminal.Error + // recvProxy is populated with the configured recv function + recvProxy func() <-chan *terminal.Msg + // sendProxy is populated with the configured send function + sendProxy func(msg *terminal.Msg, timeout time.Duration) +} + +// ExpansionRelayTerminal is a relay used for expansion. +type ExpansionRelayTerminal struct { + terminal.BareTerminal + + op *ExpandOp + + id uint32 + crane *Crane + + abandoning *abool.AtomicBool + + // flowControl holds the flow control system. + flowControl terminal.FlowControl + // deliverProxy is populated with the configured deliver function + deliverProxy func(msg *terminal.Msg) *terminal.Error + // recvProxy is populated with the configured recv function + recvProxy func() <-chan *terminal.Msg + // sendProxy is populated with the configured send function + sendProxy func(msg *terminal.Msg, timeout time.Duration) +} + +// Type returns the type ID. +func (op *ExpandOp) Type() string { + return ExpandOpType +} + +// ID returns the operation ID. +func (t *ExpansionRelayTerminal) ID() uint32 { + return t.id +} + +// Ctx returns the operation context. +func (op *ExpandOp) Ctx() context.Context { + return op.ctx +} + +// Ctx returns the relay terminal context. +func (t *ExpansionRelayTerminal) Ctx() context.Context { + return t.op.ctx +} + +// Deliver delivers a message to the relay operation. +func (op *ExpandOp) Deliver(msg *terminal.Msg) *terminal.Error { + return op.deliverProxy(msg) +} + +// Deliver delivers a message to the relay terminal. +func (t *ExpansionRelayTerminal) Deliver(msg *terminal.Msg) *terminal.Error { + return t.deliverProxy(msg) +} + +// Flush writes all data in the queues. +func (op *ExpandOp) Flush(timeout time.Duration) { + if op.flowControl != nil { + op.flowControl.Flush(timeout) + } +} + +// Flush writes all data in the queues. +func (t *ExpansionRelayTerminal) Flush(timeout time.Duration) { + if t.flowControl != nil { + t.flowControl.Flush(timeout) + } +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: ExpandOpType, + Requires: terminal.MayExpand, + Start: expand, + }) +} + +func expand(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Submit metrics. + newExpandOp.Inc() + + // Check if we are running a public hub. + if !conf.PublicHub() { + return nil, terminal.ErrPermissionDenied.With("expanding is only allowed on public hubs") + } + + // Parse destination hub ID. + dstData, err := data.GetNextBlock() + if err != nil { + return nil, terminal.ErrMalformedData.With("failed to parse destination: %w", err) + } + + // Parse terminal options. + opts, tErr := terminal.ParseTerminalOpts(data) + if tErr != nil { + return nil, tErr.Wrap("failed to parse terminal options") + } + + // Get crane with destination. + relayCrane := GetAssignedCrane(string(dstData)) + if relayCrane == nil { + return nil, terminal.ErrHubUnavailable.With("no crane assigned to %q", string(dstData)) + } + + // TODO: Expand outside of hot path. + + // Create operation and terminal. + op := &ExpandOp{ + opts: opts, + dataRelayed: new(uint64), + ended: abool.New(), + relayTerminal: &ExpansionRelayTerminal{ + crane: relayCrane, + id: relayCrane.getNextTerminalID(), + abandoning: abool.New(), + }, + } + op.InitOperationBase(t, opID) + op.ctx, op.cancelCtx = context.WithCancel(t.Ctx()) + op.relayTerminal.op = op + + // Create flow control. + switch opts.FlowControl { + case terminal.FlowControlDFQ: + // Operation + op.flowControl = terminal.NewDuplexFlowQueue(op.ctx, opts.FlowControlSize, op.submitBackwardUpstream) + op.deliverProxy = op.flowControl.Deliver + op.recvProxy = op.flowControl.Receive + op.sendProxy = op.submitBackwardFlowControl + // Relay Terminal + op.relayTerminal.flowControl = terminal.NewDuplexFlowQueue(op.ctx, opts.FlowControlSize, op.submitForwardUpstream) + op.relayTerminal.deliverProxy = op.relayTerminal.flowControl.Deliver + op.relayTerminal.recvProxy = op.relayTerminal.flowControl.Receive + op.relayTerminal.sendProxy = op.submitForwardFlowControl + case terminal.FlowControlNone: + // Operation + deliverToOp := make(chan *terminal.Msg, opts.FlowControlSize) + op.deliverProxy = terminal.MakeDirectDeliveryDeliverFunc(op.ctx, deliverToOp) + op.recvProxy = terminal.MakeDirectDeliveryRecvFunc(deliverToOp) + op.sendProxy = op.submitBackwardUpstream + // Relay Terminal + deliverToRelay := make(chan *terminal.Msg, opts.FlowControlSize) + op.relayTerminal.deliverProxy = terminal.MakeDirectDeliveryDeliverFunc(op.ctx, deliverToRelay) + op.relayTerminal.recvProxy = terminal.MakeDirectDeliveryRecvFunc(deliverToRelay) + op.relayTerminal.sendProxy = op.submitForwardUpstream + case terminal.FlowControlDefault: + fallthrough + default: + return nil, terminal.ErrInternalError.With("unknown flow control type %d", opts.FlowControl) + } + + // Establish terminal on destination. + newInitData, tErr := opts.Pack() + if tErr != nil { + return nil, terminal.ErrInternalError.With("failed to re-pack options: %w", err) + } + tErr = op.relayTerminal.crane.EstablishNewTerminal(op.relayTerminal, newInitData) + if tErr != nil { + return nil, tErr + } + + // Start workers. + module.StartWorker("expand op forward relay", op.forwardHandler) + module.StartWorker("expand op backward relay", op.backwardHandler) + if op.flowControl != nil { + op.flowControl.StartWorkers(module, "expand op") + } + if op.relayTerminal.flowControl != nil { + op.relayTerminal.flowControl.StartWorkers(module, "expand op terminal") + } + + return op, nil +} + +func (op *ExpandOp) submitForwardFlowControl(msg *terminal.Msg, timeout time.Duration) { + err := op.relayTerminal.flowControl.Send(msg, timeout) + if err != nil { + msg.Finish() + op.Stop(op, err.Wrap("failed to submit to forward flow control")) + } +} + +func (op *ExpandOp) submitBackwardFlowControl(msg *terminal.Msg, timeout time.Duration) { + err := op.flowControl.Send(msg, timeout) + if err != nil { + msg.Finish() + op.Stop(op, err.Wrap("failed to submit to backward flow control")) + } +} + +func (op *ExpandOp) submitForwardUpstream(msg *terminal.Msg, timeout time.Duration) { + msg.FlowID = op.relayTerminal.id + if msg.Unit.IsHighPriority() && op.opts.UsePriorityDataMsgs { + msg.Type = terminal.MsgTypePriorityData + } else { + msg.Type = terminal.MsgTypeData + } + err := op.relayTerminal.crane.Send(msg, timeout) + if err != nil { + msg.Finish() + op.Stop(op, err.Wrap("failed to submit to forward upstream")) + } +} + +func (op *ExpandOp) submitBackwardUpstream(msg *terminal.Msg, timeout time.Duration) { + msg.FlowID = op.relayTerminal.id + if msg.Unit.IsHighPriority() && op.opts.UsePriorityDataMsgs { + msg.Type = terminal.MsgTypePriorityData + } else { + msg.Type = terminal.MsgTypeData + msg.Unit.RemovePriority() + } + // Note: op.Send() will transform high priority units to priority data msgs. + err := op.Send(msg, timeout) + if err != nil { + msg.Finish() + op.Stop(op, err.Wrap("failed to submit to backward upstream")) + } +} + +func (op *ExpandOp) forwardHandler(_ context.Context) error { + // Metrics setup and submitting. + atomic.AddInt64(activeExpandOps, 1) + started := time.Now() + defer func() { + atomic.AddInt64(activeExpandOps, -1) + expandOpDurationHistogram.UpdateDuration(started) + expandOpRelayedDataHistogram.Update(float64(atomic.LoadUint64(op.dataRelayed))) + }() + + for { + select { + case msg := <-op.recvProxy(): + // Debugging: + // log.Debugf("spn/testing: forwarding at %s: %s", op.FmtID(), spew.Sdump(c.CompileData())) + + // Wait for processing slot. + msg.Unit.WaitForSlot() + + // Count relayed data for metrics. + atomic.AddUint64(op.dataRelayed, uint64(msg.Data.Length())) + + // Receive data from the origin and forward it to the relay. + op.relayTerminal.sendProxy(msg, 1*time.Minute) + + case <-op.ctx.Done(): + return nil + } + } +} + +func (op *ExpandOp) backwardHandler(_ context.Context) error { + for { + select { + case msg := <-op.relayTerminal.recvProxy(): + // Debugging: + // log.Debugf("spn/testing: backwarding at %s: %s", op.FmtID(), spew.Sdump(c.CompileData())) + + // Wait for processing slot. + msg.Unit.WaitForSlot() + + // Count relayed data for metrics. + atomic.AddUint64(op.dataRelayed, uint64(msg.Data.Length())) + + // Receive data from the relay and forward it to the origin. + op.sendProxy(msg, 1*time.Minute) + + case <-op.ctx.Done(): + return nil + } + } +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *ExpandOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + // Flush all messages before stopping. + op.Flush(1 * time.Minute) + op.relayTerminal.Flush(1 * time.Minute) + + // Stop connected workers. + op.cancelCtx() + + // Abandon connected terminal. + op.relayTerminal.Abandon(nil) + + // Add context to error. + if err.IsError() { + return err.Wrap("relay operation failed with") + } + return err +} + +// Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon(). +func (t *ExpansionRelayTerminal) Abandon(err *terminal.Error) { + if t.abandoning.SetToIf(false, true) { + module.StartWorker("terminal abandon procedure", func(_ context.Context) error { + t.handleAbandonProcedure(err) + return nil + }) + } +} + +// HandleAbandon gives the terminal the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Abandon() instead. +func (t *ExpansionRelayTerminal) HandleAbandon(err *terminal.Error) (errorToSend *terminal.Error) { + // Stop the connected relay operation. + t.op.Stop(t.op, err) + + // Add context to error. + if err.IsError() { + return err.Wrap("relay terminal failed with") + } + return err +} + +// HandleDestruction gives the terminal the ability to clean up. +// The terminal has already fully shut down at this point. +// Should never be called directly. Call Abandon() instead. +func (t *ExpansionRelayTerminal) HandleDestruction(err *terminal.Error) {} + +func (t *ExpansionRelayTerminal) handleAbandonProcedure(err *terminal.Error) { + // Call operation stop handle function for proper shutdown cleaning up. + err = t.HandleAbandon(err) + + // Flush all messages before stopping. + t.Flush(1 * time.Minute) + + // Send error to the connected Operation, if the error is internal. + if !err.IsExternal() { + if err == nil { + err = terminal.ErrStopping + } + + msg := terminal.NewMsg(err.Pack()) + msg.FlowID = t.ID() + msg.Type = terminal.MsgTypeStop + t.op.submitForwardUpstream(msg, 1*time.Second) + } +} + +// FmtID returns the expansion ID hierarchy. +func (op *ExpandOp) FmtID() string { + return fmt.Sprintf("%s>%d %s#%d", op.Terminal().FmtID(), op.ID(), op.relayTerminal.crane.ID, op.relayTerminal.id) +} + +// FmtID returns the expansion ID hierarchy. +func (t *ExpansionRelayTerminal) FmtID() string { + return fmt.Sprintf("%s#%d", t.crane.ID, t.id) +} diff --git a/spn/docks/op_latency.go b/spn/docks/op_latency.go new file mode 100644 index 00000000..02c38f78 --- /dev/null +++ b/spn/docks/op_latency.go @@ -0,0 +1,298 @@ +package docks + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/log" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + // LatencyTestOpType is the type ID of the latency test operation. + LatencyTestOpType = "latency" + + latencyPingRequest = 1 + latencyPingResponse = 2 + + latencyTestNonceSize = 16 + latencyTestRuns = 10 +) + +var ( + latencyTestPauseDuration = 1 * time.Second + latencyTestOpTimeout = latencyTestRuns * latencyTestPauseDuration * 3 +) + +// LatencyTestOp is used to measure latency. +type LatencyTestOp struct { + terminal.OperationBase +} + +// LatencyTestClientOp is the client version of LatencyTestOp. +type LatencyTestClientOp struct { + LatencyTestOp + + lastPingSentAt time.Time + lastPingNonce []byte + measuredLatencies []time.Duration + responses chan *terminal.Msg + testResult time.Duration + + result chan *terminal.Error +} + +// Type returns the type ID. +func (op *LatencyTestOp) Type() string { + return LatencyTestOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: LatencyTestOpType, + Requires: terminal.IsCraneController, + Start: startLatencyTestOp, + }) +} + +// NewLatencyTestOp runs a latency test. +func NewLatencyTestOp(t terminal.Terminal) (*LatencyTestClientOp, *terminal.Error) { + // Create and init. + op := &LatencyTestClientOp{ + responses: make(chan *terminal.Msg), + measuredLatencies: make([]time.Duration, 0, latencyTestRuns), + result: make(chan *terminal.Error, 1), + } + + // Make ping request. + pingRequest, err := op.createPingRequest() + if err != nil { + return nil, terminal.ErrInternalError.With("%w", err) + } + + // Send ping. + tErr := t.StartOperation(op, pingRequest, 1*time.Second) + if tErr != nil { + return nil, tErr + } + + // Start handler. + module.StartWorker("op latency handler", op.handler) + + return op, nil +} + +func (op *LatencyTestClientOp) handler(ctx context.Context) error { + returnErr := terminal.ErrStopping + defer func() { + // Linters don't get that returnErr is used when directly used as defer. + op.Stop(op, returnErr) + }() + + var nextTest <-chan time.Time + opTimeout := time.After(latencyTestOpTimeout) + + for { + select { + case <-ctx.Done(): + return nil + + case <-opTimeout: + return nil + + case <-nextTest: + // Create ping request msg. + pingRequest, err := op.createPingRequest() + if err != nil { + returnErr = terminal.ErrInternalError.With("%w", err) + return nil + } + msg := op.NewEmptyMsg() + msg.Unit.MakeHighPriority() + msg.Data = pingRequest + + // Send it. + tErr := op.Send(msg, latencyTestOpTimeout) + if tErr != nil { + returnErr = tErr.Wrap("failed to send ping request") + return nil + } + op.Flush(1 * time.Second) + + nextTest = nil + + case msg := <-op.responses: + // Check if the op ended. + if msg == nil { + return nil + } + + // Handle response + tErr := op.handleResponse(msg) + if tErr != nil { + returnErr = tErr + return nil //nolint:nilerr + } + + // Check if we have enough latency tests. + if len(op.measuredLatencies) >= latencyTestRuns { + returnErr = op.reportMeasuredLatencies() + return nil + } + + // Schedule next latency test, if not yet scheduled. + if nextTest == nil { + nextTest = time.After(latencyTestPauseDuration) + } + } + } +} + +func (op *LatencyTestClientOp) createPingRequest() (*container.Container, error) { + // Generate nonce. + nonce, err := rng.Bytes(latencyTestNonceSize) + if err != nil { + return nil, fmt.Errorf("failed to create ping nonce") + } + + // Set client request state. + op.lastPingSentAt = time.Now() + op.lastPingNonce = nonce + + return container.New( + varint.Pack8(latencyPingRequest), + nonce, + ), nil +} + +func (op *LatencyTestClientOp) handleResponse(msg *terminal.Msg) *terminal.Error { + defer msg.Finish() + + rType, err := msg.Data.GetNextN8() + if err != nil { + return terminal.ErrMalformedData.With("failed to get response type: %w", err) + } + + switch rType { + case latencyPingResponse: + // Check if the ping nonce matches. + if !bytes.Equal(op.lastPingNonce, msg.Data.CompileData()) { + return terminal.ErrIntegrity.With("ping nonce mismatch") + } + op.lastPingNonce = nil + // Save latency. + op.measuredLatencies = append(op.measuredLatencies, time.Since(op.lastPingSentAt)) + + return nil + default: + return terminal.ErrIncorrectUsage.With("unknown response type") + } +} + +func (op *LatencyTestClientOp) reportMeasuredLatencies() *terminal.Error { + // Find lowest value. + lowestLatency := time.Hour + for _, latency := range op.measuredLatencies { + if latency < lowestLatency { + lowestLatency = latency + } + } + op.testResult = lowestLatency + + // Save the result to the crane. + if controller, ok := op.Terminal().(*CraneControllerTerminal); ok { + if controller.Crane.ConnectedHub != nil { + controller.Crane.ConnectedHub.GetMeasurements().SetLatency(op.testResult) + log.Infof("spn/docks: measured latency to %s: %s", controller.Crane.ConnectedHub, op.testResult) + return nil + } else if controller.Crane.IsMine() { + return terminal.ErrInternalError.With("latency operation was run on %s without a connected hub set", controller.Crane) + } + } else if !runningTests { + return terminal.ErrInternalError.With("latency operation was run on terminal that is not a crane controller, but %T", op.Terminal()) + } + return nil +} + +// Deliver delivers a message to the operation. +func (op *LatencyTestClientOp) Deliver(msg *terminal.Msg) *terminal.Error { + // Optimized delivery with 1s timeout. + select { + case op.responses <- msg: + default: + select { + case op.responses <- msg: + case <-time.After(1 * time.Second): + return terminal.ErrTimeout + } + } + return nil +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *LatencyTestClientOp) HandleStop(tErr *terminal.Error) (errorToSend *terminal.Error) { + close(op.responses) + select { + case op.result <- tErr: + default: + } + return tErr +} + +// Result returns the result (end error) of the operation. +func (op *LatencyTestClientOp) Result() <-chan *terminal.Error { + return op.result +} + +func startLatencyTestOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Create operation. + op := &LatencyTestOp{} + op.InitOperationBase(t, opID) + + // Handle first request. + msg := op.NewEmptyMsg() + msg.Data = data + tErr := op.Deliver(msg) + if tErr != nil { + return nil, tErr + } + + return op, nil +} + +// Deliver delivers a message to the operation. +func (op *LatencyTestOp) Deliver(msg *terminal.Msg) *terminal.Error { + // Get request type. + rType, err := msg.Data.GetNextN8() + if err != nil { + return terminal.ErrMalformedData.With("failed to get response type: %w", err) + } + + switch rType { + case latencyPingRequest: + // Keep the nonce and just replace the msg type. + msg.Data.PrependNumber(latencyPingResponse) + msg.Type = terminal.MsgTypeData + msg.Unit.ReUse() + msg.Unit.MakeHighPriority() + + // Send response. + tErr := op.Send(msg, latencyTestOpTimeout) + if tErr != nil { + return tErr.Wrap("failed to send ping response") + } + op.Flush(1 * time.Second) + + return nil + + default: + return terminal.ErrIncorrectUsage.With("unknown request type") + } +} diff --git a/spn/docks/op_latency_test.go b/spn/docks/op_latency_test.go new file mode 100644 index 00000000..7a0b4ec7 --- /dev/null +++ b/spn/docks/op_latency_test.go @@ -0,0 +1,59 @@ +package docks + +import ( + "testing" + "time" + + "github.com/safing/portmaster/spn/terminal" +) + +func TestLatencyOp(t *testing.T) { + t.Parallel() + + var ( + latTestDelay = 10 * time.Millisecond + latTestQueueSize uint32 = 10 + ) + + // Reduce waiting time. + latencyTestPauseDuration = 100 * time.Millisecond + + // Create test terminal pair. + a, b, err := terminal.NewSimpleTestTerminalPair( + latTestDelay, + int(latTestQueueSize), + &terminal.TerminalOpts{ + FlowControl: terminal.FlowControlNone, + FlowControlSize: latTestQueueSize, + }, + ) + if err != nil { + t.Fatalf("failed to create test terminal pair: %s", err) + } + + // Grant permission for op on remote terminal and start op. + b.GrantPermission(terminal.IsCraneController) + op, tErr := NewLatencyTestOp(a) + if tErr != nil { + t.Fatalf("failed to start op: %s", err) + } + + // Wait for result and check error. + tErr = <-op.Result() + if tErr.IsError() { + t.Fatalf("op failed: %s", tErr) + } + t.Logf("measured latency: %f ms", float64(op.testResult)/float64(time.Millisecond)) + + // Calculate expected latency. + expectedLatency := float64(latTestDelay * 2) + t.Logf("expected latency: %f ms", expectedLatency/float64(time.Millisecond)) + + // Check if measured latency is within parameters. + if float64(op.testResult) > expectedLatency*1.2 { + t.Fatal("measured latency too high") + } + if float64(op.testResult) < expectedLatency*0.9 { + t.Fatal("measured latency too low") + } +} diff --git a/spn/docks/op_sync_state.go b/spn/docks/op_sync_state.go new file mode 100644 index 00000000..43530803 --- /dev/null +++ b/spn/docks/op_sync_state.go @@ -0,0 +1,150 @@ +package docks + +import ( + "context" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/terminal" +) + +// SyncStateOpType is the type ID of the sync state operation. +const SyncStateOpType = "sync/state" + +// SyncStateOp is used to sync the crane state. +type SyncStateOp struct { + terminal.OneOffOperationBase +} + +// SyncStateMessage holds the sync data. +type SyncStateMessage struct { + Stopping bool + RequestStopping bool +} + +// Type returns the type ID. +func (op *SyncStateOp) Type() string { + return SyncStateOpType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: SyncStateOpType, + Requires: terminal.IsCraneController, + Start: runSyncStateOp, + }) +} + +// startSyncStateOp starts a worker that runs the sync state operation. +func (crane *Crane) startSyncStateOp() { + module.StartWorker("sync crane state", func(ctx context.Context) error { + tErr := crane.Controller.SyncState(ctx) + if tErr != nil { + return tErr + } + + return nil + }) +} + +// SyncState runs a sync state operation. +func (controller *CraneControllerTerminal) SyncState(ctx context.Context) *terminal.Error { + // Check if we are a public Hub, whether we own the crane and whether the lane is public too. + if !conf.PublicHub() || !controller.Crane.Public() { + return nil + } + + // Create and init. + op := &SyncStateOp{} + op.Init() + + // Get optimization states. + requestStopping := false + func() { + controller.Crane.NetState.lock.Lock() + defer controller.Crane.NetState.lock.Unlock() + + requestStopping = controller.Crane.NetState.stoppingRequested + }() + + // Create sync message. + msg := &SyncStateMessage{ + Stopping: controller.Crane.stopping.IsSet(), + RequestStopping: requestStopping, + } + data, err := dsd.Dump(msg, dsd.CBOR) + if err != nil { + return terminal.ErrInternalError.With("%w", err) + } + + // Send message. + tErr := controller.StartOperation(op, container.New(data), 30*time.Second) + if tErr != nil { + return tErr + } + + // Wait for reply + select { + case tErr = <-op.Result: + if tErr.IsError() { + return tErr + } + return nil + case <-ctx.Done(): + return nil + case <-time.After(1 * time.Minute): + return terminal.ErrTimeout.With("timed out while waiting for sync crane result") + } +} + +func runSyncStateOp(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Check if we are a on a crane controller. + var ok bool + var controller *CraneControllerTerminal + if controller, ok = t.(*CraneControllerTerminal); !ok { + return nil, terminal.ErrIncorrectUsage.With("can only be used with a crane controller") + } + + // Check if we are a public Hub and whether the lane is public too. + if !conf.PublicHub() || !controller.Crane.Public() { + return nil, terminal.ErrPermissionDenied.With("only public lanes can sync crane status") + } + + // Load message. + syncState := &SyncStateMessage{} + _, err := dsd.Load(data.CompileData(), syncState) + if err != nil { + return nil, terminal.ErrMalformedData.With("failed to load sync state message: %w", err) + } + + // Apply optimization state. + controller.Crane.NetState.lock.Lock() + defer controller.Crane.NetState.lock.Unlock() + controller.Crane.NetState.stoppingRequestedByPeer = syncState.RequestStopping + + // Apply crane state only when we don't own the crane. + if !controller.Crane.IsMine() { + // Apply sync state. + var changed bool + if syncState.Stopping { + if controller.Crane.stopping.SetToIf(false, true) { + controller.Crane.NetState.markedStoppingAt = time.Now() + changed = true + } + } else { + if controller.Crane.stopping.SetToIf(true, false) { + controller.Crane.NetState.markedStoppingAt = time.Time{} + changed = true + } + } + + // Notify of change. + if changed { + controller.Crane.NotifyUpdate() + } + } + + return nil, nil +} diff --git a/spn/docks/op_whoami.go b/spn/docks/op_whoami.go new file mode 100644 index 00000000..baf5204c --- /dev/null +++ b/spn/docks/op_whoami.go @@ -0,0 +1,135 @@ +package docks + +import ( + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portmaster/spn/terminal" +) + +const ( + // WhoAmIType is the type ID of the latency test operation. + WhoAmIType = "whoami" + + whoAmITimeout = 3 * time.Second +) + +// WhoAmIOp is used to request some metadata about the other side. +type WhoAmIOp struct { + terminal.OneOffOperationBase + + response *WhoAmIResponse +} + +// WhoAmIResponse is a whoami response. +type WhoAmIResponse struct { + // Timestamp in nanoseconds + Timestamp int64 `cbor:"t,omitempty" json:"t,omitempty"` + + // Addr is the remote address as reported by the crane terminal (IP and port). + Addr string `cbor:"a,omitempty" json:"a,omitempty"` +} + +// Type returns the type ID. +func (op *WhoAmIOp) Type() string { + return WhoAmIType +} + +func init() { + terminal.RegisterOpType(terminal.OperationFactory{ + Type: WhoAmIType, + Start: startWhoAmI, + }) +} + +// WhoAmI executes a whoami operation and returns the response. +func WhoAmI(t terminal.Terminal) (*WhoAmIResponse, *terminal.Error) { + whoami, err := NewWhoAmIOp(t) + if err.IsError() { + return nil, err + } + + // Wait for response. + select { + case tErr := <-whoami.Result: + if tErr.IsError() { + return nil, tErr + } + return whoami.response, nil + case <-time.After(whoAmITimeout * 2): + return nil, terminal.ErrTimeout + } +} + +// NewWhoAmIOp starts a new whoami operation. +func NewWhoAmIOp(t terminal.Terminal) (*WhoAmIOp, *terminal.Error) { + // Create operation and init. + op := &WhoAmIOp{} + op.OneOffOperationBase.Init() + + // Send ping. + tErr := t.StartOperation(op, nil, whoAmITimeout) + if tErr != nil { + return nil, tErr + } + + return op, nil +} + +// Deliver delivers a message to the operation. +func (op *WhoAmIOp) Deliver(msg *terminal.Msg) *terminal.Error { + defer msg.Finish() + + // Parse response. + response := &WhoAmIResponse{} + _, err := dsd.Load(msg.Data.CompileData(), response) + if err != nil { + return terminal.ErrMalformedData.With("failed to parse ping response: %w", err) + } + + op.response = response + return terminal.ErrExplicitAck +} + +func startWhoAmI(t terminal.Terminal, opID uint32, data *container.Container) (terminal.Operation, *terminal.Error) { + // Get crane terminal, if available. + ct, _ := t.(*CraneTerminal) + + // Create response. + r := &WhoAmIResponse{ + Timestamp: time.Now().UnixNano(), + } + if ct != nil { + r.Addr = ct.RemoteAddr().String() + } + response, err := dsd.Dump(r, dsd.CBOR) + if err != nil { + return nil, terminal.ErrInternalError.With("failed to create whoami response: %w", err) + } + + // Send response. + msg := terminal.NewMsg(response) + msg.FlowID = opID + msg.Unit.MakeHighPriority() + if terminal.UsePriorityDataMsgs { + msg.Type = terminal.MsgTypePriorityData + } + tErr := t.Send(msg, whoAmITimeout) + if tErr != nil { + // Finish message unit on failure. + msg.Finish() + return nil, tErr.With("failed to send ping response") + } + + // Operation is just one response and finished successfully. + return nil, nil +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *WhoAmIOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + // Continue with usual handling of inherited base. + return op.OneOffOperationBase.HandleStop(err) +} diff --git a/spn/docks/op_whoami_test.go b/spn/docks/op_whoami_test.go new file mode 100644 index 00000000..9ce32763 --- /dev/null +++ b/spn/docks/op_whoami_test.go @@ -0,0 +1,24 @@ +package docks + +import ( + "testing" + + "github.com/safing/portmaster/spn/terminal" +) + +func TestWhoAmIOp(t *testing.T) { + t.Parallel() + + // Create test terminal pair. + a, _, err := terminal.NewSimpleTestTerminalPair(0, 0, nil) + if err != nil { + t.Fatalf("failed to create test terminal pair: %s", err) + } + + // Run op. + resp, tErr := WhoAmI(a) + if tErr.IsError() { + t.Fatal(tErr) + } + t.Logf("whoami: %+v", resp) +} diff --git a/spn/docks/terminal_expansion.go b/spn/docks/terminal_expansion.go new file mode 100644 index 00000000..16895a83 --- /dev/null +++ b/spn/docks/terminal_expansion.go @@ -0,0 +1,150 @@ +package docks + +import ( + "fmt" + "sync" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/terminal" +) + +// ExpansionTerminal is used for expanding to another Hub. +type ExpansionTerminal struct { + *terminal.TerminalBase + + relayOp *ExpansionTerminalRelayOp + + changeNotifyFuncReady *abool.AtomicBool + changeNotifyFunc func() + + reachableChecked time.Time + reachableLock sync.Mutex +} + +// ExpansionTerminalRelayOp is the operation that connects to the relay. +type ExpansionTerminalRelayOp struct { + terminal.OperationBase + + expansionTerminal *ExpansionTerminal +} + +// Type returns the type ID. +func (op *ExpansionTerminalRelayOp) Type() string { + return ExpandOpType +} + +// ExpandTo initiates an expansion. +func ExpandTo(from terminal.Terminal, routeTo string, encryptFor *hub.Hub) (*ExpansionTerminal, *terminal.Error) { + // First, create the local endpoint terminal to generate the init data. + + // Create options and bare expansion terminal. + opts := terminal.DefaultExpansionTerminalOpts() + opts.Encrypt = encryptFor != nil + expansion := &ExpansionTerminal{ + changeNotifyFuncReady: abool.New(), + } + expansion.relayOp = &ExpansionTerminalRelayOp{ + expansionTerminal: expansion, + } + + // Create base terminal for expansion. + base, initData, tErr := terminal.NewLocalBaseTerminal( + module.Ctx, + 0, // Ignore; The ID of the operation is used for communication. + from.FmtID(), + encryptFor, + opts, + expansion.relayOp, + ) + if tErr != nil { + return nil, tErr.Wrap("failed to create expansion terminal base") + } + expansion.TerminalBase = base + base.SetTerminalExtension(expansion) + base.SetTimeout(defaultTerminalIdleTimeout) + + // Second, start the actual relay operation. + + // Create setup message for relay operation. + opInitData := container.New() + opInitData.AppendAsBlock([]byte(routeTo)) + opInitData.AppendContainer(initData) + + // Start relay operation on connected Hub. + tErr = from.StartOperation(expansion.relayOp, opInitData, 5*time.Second) + if tErr != nil { + return nil, tErr.Wrap("failed to start expansion operation") + } + + // Start Workers. + base.StartWorkers(module, "expansion terminal") + + return expansion, nil +} + +// SetChangeNotifyFunc sets a callback function that is called when the terminal state changes. +func (t *ExpansionTerminal) SetChangeNotifyFunc(f func()) { + if t.changeNotifyFuncReady.IsSet() { + return + } + t.changeNotifyFunc = f + t.changeNotifyFuncReady.Set() +} + +// NeedsReachableCheck returns whether the terminal should be checked if it is +// reachable via the existing network internal relayed connection. +func (t *ExpansionTerminal) NeedsReachableCheck(maxCheckAge time.Duration) bool { + t.reachableLock.Lock() + defer t.reachableLock.Unlock() + + return time.Since(t.reachableChecked) > maxCheckAge +} + +// MarkReachable marks the terminal as reachable via the existing network +// internal relayed connection. +func (t *ExpansionTerminal) MarkReachable() { + t.reachableLock.Lock() + defer t.reachableLock.Unlock() + + t.reachableChecked = time.Now() +} + +// HandleDestruction gives the terminal the ability to clean up. +// The terminal has already fully shut down at this point. +// Should never be called directly. Call Abandon() instead. +func (t *ExpansionTerminal) HandleDestruction(err *terminal.Error) { + // Trigger update of connected Pin. + if t.changeNotifyFuncReady.IsSet() { + t.changeNotifyFunc() + } + + // Stop the relay operation. + // The error message is arlready sent by the terminal. + t.relayOp.Stop(t.relayOp, nil) +} + +// CustomIDFormat formats the terminal ID. +func (t *ExpansionTerminal) CustomIDFormat() string { + return fmt.Sprintf("%s~%d", t.relayOp.Terminal().FmtID(), t.relayOp.ID()) +} + +// Deliver delivers a message to the operation. +func (op *ExpansionTerminalRelayOp) Deliver(msg *terminal.Msg) *terminal.Error { + // Proxy directly to expansion terminal. + return op.expansionTerminal.Deliver(msg) +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *ExpansionTerminalRelayOp) HandleStop(err *terminal.Error) (errorToSend *terminal.Error) { + // Stop the expansion terminal. + // The error message will be sent by the operation. + op.expansionTerminal.Abandon(nil) + + return err +} diff --git a/spn/docks/terminal_expansion_test.go b/spn/docks/terminal_expansion_test.go new file mode 100644 index 00000000..415716ea --- /dev/null +++ b/spn/docks/terminal_expansion_test.go @@ -0,0 +1,305 @@ +package docks + +import ( + "fmt" + "os" + "runtime/pprof" + "sync" + "testing" + "time" + + "github.com/safing/portmaster/spn/access" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/hub" + "github.com/safing/portmaster/spn/ships" + "github.com/safing/portmaster/spn/terminal" +) + +const defaultTestQueueSize = 200 + +func TestExpansion(t *testing.T) { + t.Parallel() + + // Test without and with encryption. + for _, encrypt := range []bool{false, true} { + // Test down/up separately and in parallel. + for _, parallel := range []bool{false, true} { + // Test with different flow controls. + for _, fc := range []struct { + flowControl terminal.FlowControlType + flowControlSize uint32 + }{ + { + flowControl: terminal.FlowControlNone, + flowControlSize: 5, + }, + { + flowControl: terminal.FlowControlDFQ, + flowControlSize: defaultTestQueueSize, + }, + } { + // Run tests with combined options. + testExpansion( + t, + "expansion-hop-test", + &terminal.TerminalOpts{ + Encrypt: encrypt, + Padding: 8, + FlowControl: fc.flowControl, + FlowControlSize: fc.flowControlSize, + }, + defaultTestQueueSize, + defaultTestQueueSize, + parallel, + ) + } + } + } + + stressTestOpts := &terminal.TerminalOpts{ + Encrypt: true, + Padding: 8, + FlowControl: terminal.FlowControlDFQ, + FlowControlSize: defaultTestQueueSize, + } + testExpansion(t, "expansion-stress-test-down", stressTestOpts, defaultTestQueueSize*100, 0, false) + testExpansion(t, "expansion-stress-test-up", stressTestOpts, 0, defaultTestQueueSize*100, false) + testExpansion(t, "expansion-stress-test-duplex", stressTestOpts, defaultTestQueueSize*100, defaultTestQueueSize*100, false) +} + +func testExpansion( //nolint:maintidx,thelper + t *testing.T, + testID string, + terminalOpts *terminal.TerminalOpts, + clientCountTo, + serverCountTo uint64, + inParallel bool, +) { + testID += fmt.Sprintf(":encrypt=%v,flowType=%d,parallel=%v", terminalOpts.Encrypt, terminalOpts.FlowControl, inParallel) + + var identity2, identity3, identity4 *cabin.Identity + var connectedHub2, connectedHub3, connectedHub4 *hub.Hub + if terminalOpts.Encrypt { + identity2, connectedHub2 = getTestIdentity(t) + identity3, connectedHub3 = getTestIdentity(t) + identity4, connectedHub4 = getTestIdentity(t) + } + + // Build ships and cranes. + optimalMinLoadSize = 100 + ship1to2 := ships.NewTestShip(!terminalOpts.Encrypt, 100) + ship2to3 := ships.NewTestShip(!terminalOpts.Encrypt, 100) + ship3to4 := ships.NewTestShip(!terminalOpts.Encrypt, 100) + + var crane1, crane2to1, crane2to3, crane3to2, crane3to4, crane4 *Crane + var craneWg sync.WaitGroup + craneWg.Add(6) + + go func() { + var err error + crane1, err = NewCrane(ship1to2, connectedHub2, nil) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not create crane1: %s", testID, err)) + } + crane1.ID = "c1" + err = crane1.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not start crane1: %s", testID, err)) + } + crane1.ship.MarkPublic() + craneWg.Done() + }() + go func() { + var err error + crane2to1, err = NewCrane(ship1to2.Reverse(), nil, identity2) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not create crane2to1: %s", testID, err)) + } + crane2to1.ID = "c2to1" + err = crane2to1.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not start crane2to1: %s", testID, err)) + } + crane2to1.ship.MarkPublic() + craneWg.Done() + }() + go func() { + var err error + crane2to3, err = NewCrane(ship2to3, connectedHub3, nil) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not create crane2to3: %s", testID, err)) + } + crane2to3.ID = "c2to3" + err = crane2to3.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not start crane2to3: %s", testID, err)) + } + crane2to3.ship.MarkPublic() + craneWg.Done() + }() + go func() { + var err error + crane3to2, err = NewCrane(ship2to3.Reverse(), nil, identity3) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not create crane3to2: %s", testID, err)) + } + crane3to2.ID = "c3to2" + err = crane3to2.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not start crane3to2: %s", testID, err)) + } + crane3to2.ship.MarkPublic() + craneWg.Done() + }() + go func() { + var err error + crane3to4, err = NewCrane(ship3to4, connectedHub4, nil) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not create crane3to4: %s", testID, err)) + } + crane3to4.ID = "c3to4" + err = crane3to4.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not start crane3to4: %s", testID, err)) + } + crane3to4.ship.MarkPublic() + craneWg.Done() + }() + go func() { + var err error + crane4, err = NewCrane(ship3to4.Reverse(), nil, identity4) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not create crane4: %s", testID, err)) + } + crane4.ID = "c4" + err = crane4.Start(module.Ctx) + if err != nil { + panic(fmt.Sprintf("expansion test %s could not start crane4: %s", testID, err)) + } + crane4.ship.MarkPublic() + craneWg.Done() + }() + craneWg.Wait() + + // Assign cranes. + crane3HubID := testID + "-crane3HubID" + AssignCrane(crane3HubID, crane2to3) + crane4HubID := testID + "-crane4HubID" + AssignCrane(crane4HubID, crane3to4) + + t.Logf("expansion test %s: initial setup complete", testID) + + // Wait async for test to complete, print stack after timeout. + finished := make(chan struct{}) + go func() { + select { + case <-finished: + case <-time.After(30 * time.Second): + fmt.Printf("expansion test %s is taking too long, print stack:\n", testID) + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) + os.Exit(1) + } + }() + + // Start initial crane. + homeTerminal, initData, tErr := NewLocalCraneTerminal(crane1, nil, &terminal.TerminalOpts{}) + if tErr != nil { + t.Fatalf("expansion test %s failed to create home terminal: %s", testID, tErr) + } + tErr = crane1.EstablishNewTerminal(homeTerminal, initData) + if tErr != nil { + t.Fatalf("expansion test %s failed to connect home terminal: %s", testID, tErr) + } + + t.Logf("expansion test %s: home terminal setup complete", testID) + time.Sleep(100 * time.Millisecond) + + // Start counters for testing. + op0, tErr := terminal.NewCounterOp(homeTerminal, terminal.CounterOpts{ + ClientCountTo: clientCountTo, + ServerCountTo: serverCountTo, + }) + if tErr != nil { + t.Fatalf("expansion test %s failed to run counter op: %s", testID, tErr) + } + t.Logf("expansion test %s: home terminal counter setup complete", testID) + if !inParallel { + op0.Wait() + } + + // Start expansion to crane 3. + opAuthTo2, tErr := access.AuthorizeToTerminal(homeTerminal) + if tErr != nil { + t.Fatalf("expansion test %s failed to auth with home terminal: %s", testID, tErr) + } + tErr = <-opAuthTo2.Result + if tErr.IsError() { + t.Fatalf("expansion test %s failed to auth with home terminal: %s", testID, tErr) + } + expansionTerminalTo3, err := ExpandTo(homeTerminal, crane3HubID, connectedHub3) + if err != nil { + t.Fatalf("expansion test %s failed to expand to %s: %s", testID, crane3HubID, tErr) + } + + // Start counters for testing. + op1, tErr := terminal.NewCounterOp(expansionTerminalTo3, terminal.CounterOpts{ + ClientCountTo: clientCountTo, + ServerCountTo: serverCountTo, + }) + if tErr != nil { + t.Fatalf("expansion test %s failed to run counter op: %s", testID, tErr) + } + + t.Logf("expansion test %s: expansion to crane3 and counter setup complete", testID) + if !inParallel { + op1.Wait() + } + + // Start expansion to crane 4. + opAuthTo3, tErr := access.AuthorizeToTerminal(expansionTerminalTo3) + if tErr != nil { + t.Fatalf("expansion test %s failed to auth with extenstion terminal: %s", testID, tErr) + } + tErr = <-opAuthTo3.Result + if tErr.IsError() { + t.Fatalf("expansion test %s failed to auth with extenstion terminal: %s", testID, tErr) + } + + expansionTerminalTo4, err := ExpandTo(expansionTerminalTo3, crane4HubID, connectedHub4) + if err != nil { + t.Fatalf("expansion test %s failed to expand to %s: %s", testID, crane4HubID, tErr) + } + + // Start counters for testing. + op2, tErr := terminal.NewCounterOp(expansionTerminalTo4, terminal.CounterOpts{ + ClientCountTo: clientCountTo, + ServerCountTo: serverCountTo, + }) + if tErr != nil { + t.Fatalf("expansion test %s failed to run counter op: %s", testID, tErr) + } + + t.Logf("expansion test %s: expansion to crane4 and counter setup complete", testID) + op2.Wait() + + // Wait for op1 if not already. + if inParallel { + op0.Wait() + op1.Wait() + } + + // Wait for completion. + close(finished) + + // Wait a little so that all errors can be propagated, so we can truly see + // if we succeeded. + time.Sleep(100 * time.Millisecond) + + // Check errors. + if op1.Error != nil { + t.Fatalf("crane test %s counter op1 failed: %s", testID, op1.Error) + } + if op2.Error != nil { + t.Fatalf("crane test %s counter op2 failed: %s", testID, op2.Error) + } +} diff --git a/spn/hub/database.go b/spn/hub/database.go new file mode 100644 index 00000000..d4ca3f85 --- /dev/null +++ b/spn/hub/database.go @@ -0,0 +1,202 @@ +package hub + +import ( + "errors" + "fmt" + "sync" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/iterator" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/database/record" +) + +var ( + db = database.NewInterface(&database.Options{ + Local: true, + Internal: true, + }) + + getFromNavigator func(mapName, hubID string) *Hub +) + +// MakeHubDBKey makes a hub db key. +func MakeHubDBKey(mapName, hubID string) string { + return fmt.Sprintf("cache:spn/hubs/%s/%s", mapName, hubID) +} + +// MakeHubMsgDBKey makes a hub msg db key. +func MakeHubMsgDBKey(mapName string, msgType MsgType, hubID string) string { + return fmt.Sprintf("cache:spn/msgs/%s/%s/%s", mapName, msgType, hubID) +} + +// SetNavigatorAccess sets a shortcut function to access hubs from the navigator instead of having go through the database. +// This also reduces the number of object in RAM and better caches parsed attributes. +func SetNavigatorAccess(fn func(mapName, hubID string) *Hub) { + if getFromNavigator == nil { + getFromNavigator = fn + } +} + +// GetHub get a Hub from the database - or the navigator, if configured. +func GetHub(mapName string, hubID string) (*Hub, error) { + if getFromNavigator != nil { + hub := getFromNavigator(mapName, hubID) + if hub != nil { + return hub, nil + } + } + + return GetHubByKey(MakeHubDBKey(mapName, hubID)) +} + +// GetHubByKey returns a hub by its raw DB key. +func GetHubByKey(key string) (*Hub, error) { + r, err := db.Get(key) + if err != nil { + return nil, err + } + + hub, err := EnsureHub(r) + if err != nil { + return nil, err + } + + return hub, nil +} + +// EnsureHub makes sure a database record is a Hub. +func EnsureHub(r record.Record) (*Hub, error) { + // unwrap + if r.IsWrapped() { + // only allocate a new struct, if we need it + newHub := &Hub{} + err := record.Unwrap(r, newHub) + if err != nil { + return nil, err + } + newHub = prepHub(newHub) + + // Fully validate when getting from database. + if err := newHub.Info.validateFormatting(); err != nil { + return nil, fmt.Errorf("announcement failed format validation: %w", err) + } + if err := newHub.Status.validateFormatting(); err != nil { + return nil, fmt.Errorf("status failed format validation: %w", err) + } + if err := newHub.Info.prepare(false); err != nil { + return nil, fmt.Errorf("failed to prepare announcement: %w", err) + } + + return newHub, nil + } + + // or adjust type + newHub, ok := r.(*Hub) + if !ok { + return nil, fmt.Errorf("record not of type *Hub, but %T", r) + } + newHub = prepHub(newHub) + + // Prepare only when already parsed. + if err := newHub.Info.prepare(false); err != nil { + return nil, fmt.Errorf("failed to prepare announcement: %w", err) + } + + // ensure status + return newHub, nil +} + +func prepHub(h *Hub) *Hub { + if h.Status == nil { + h.Status = &Status{} + } + h.Measurements = getSharedMeasurements(h.ID, h.Measurements) + return h +} + +// Save saves to Hub to the correct scope in the database. +func (h *Hub) Save() error { + if !h.KeyIsSet() { + h.SetKey(MakeHubDBKey(h.Map, h.ID)) + } + + return db.Put(h) +} + +// RemoveHubAndMsgs deletes a Hub and it's saved messages from the database. +func RemoveHubAndMsgs(mapName string, hubID string) (err error) { + err = db.Delete(MakeHubDBKey(mapName, hubID)) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to delete main hub entry: %w", err) + } + + err = db.Delete(MakeHubMsgDBKey(mapName, MsgTypeAnnouncement, hubID)) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to delete hub announcement data: %w", err) + } + + err = db.Delete(MakeHubMsgDBKey(mapName, MsgTypeStatus, hubID)) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to delete hub status data: %w", err) + } + + return nil +} + +// HubMsg stores raw Hub messages. +type HubMsg struct { //nolint:golint + record.Base + sync.Mutex + + ID string + Map string + Type MsgType + Data []byte + + Received int64 +} + +// SaveHubMsg saves a raw (and signed) message received by another Hub. +func SaveHubMsg(id string, mapName string, msgType MsgType, data []byte) error { + // create wrapper record + msg := &HubMsg{ + ID: id, + Map: mapName, + Type: msgType, + Data: data, + Received: time.Now().Unix(), + } + // set key + msg.SetKey(MakeHubMsgDBKey(msg.Map, msg.Type, msg.ID)) + // save + return db.PutNew(msg) +} + +// QueryRawGossipMsgs queries the database for raw gossip messages. +func QueryRawGossipMsgs(mapName string, msgType MsgType) (it *iterator.Iterator, err error) { + it, err = db.Query(query.New(MakeHubMsgDBKey(mapName, msgType, ""))) + return +} + +// EnsureHubMsg makes sure a database record is a HubMsg. +func EnsureHubMsg(r record.Record) (*HubMsg, error) { + // unwrap + if r.IsWrapped() { + // only allocate a new struct, if we need it + newHubMsg := &HubMsg{} + err := record.Unwrap(r, newHubMsg) + if err != nil { + return nil, err + } + return newHubMsg, nil + } + + // or adjust type + newHubMsg, ok := r.(*HubMsg) + if !ok { + return nil, fmt.Errorf("record not of type *Hub, but %T", r) + } + return newHubMsg, nil +} diff --git a/spn/hub/errors.go b/spn/hub/errors.go new file mode 100644 index 00000000..276549e4 --- /dev/null +++ b/spn/hub/errors.go @@ -0,0 +1,21 @@ +package hub + +import "errors" + +var ( + // ErrMissingInfo signifies that the hub is missing the HubAnnouncement. + ErrMissingInfo = errors.New("hub has no announcement") + + // ErrMissingTransports signifies that the hub announcement did not specify any transports. + ErrMissingTransports = errors.New("hub announcement has no transports") + + // ErrMissingIPs signifies that the hub announcement did not specify any IPs, + // or none of the IPs is supported by the client. + ErrMissingIPs = errors.New("hub announcement has no (supported) IPs") + + // ErrTemporaryValidationError is returned when a validation error might be temporary. + ErrTemporaryValidationError = errors.New("temporary validation error") + + // ErrOldData is returned when received data is outdated. + ErrOldData = errors.New("") +) diff --git a/spn/hub/format.go b/spn/hub/format.go new file mode 100644 index 00000000..f36b3d0d --- /dev/null +++ b/spn/hub/format.go @@ -0,0 +1,69 @@ +package hub + +import ( + "fmt" + "net" + "regexp" + + "github.com/safing/portmaster/service/network/netutils" +) + +// BaselineCharset defines the permitted characters. +var BaselineCharset = regexp.MustCompile( + // Start of charset selection. + `^[` + + // Printable ASCII (character code 32-127), excluding common control characters of different languages: "$%&';<>\` and DELETE. + ` !#()*+,\-\./0-9:=?@A-Z[\]^_a-z{|}~` + + // Only latin characters from extended ASCII (character code 128-255). + `ŠŒŽšœžŸ¡¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ` + + // End of charset selection. + `]*$`, +) + +func checkStringFormat(fieldName, value string, maxLength int) error { + switch { + case len(value) > maxLength: + return fmt.Errorf("field %s with length of %d exceeds max length of %d", fieldName, len(value), maxLength) + case !BaselineCharset.MatchString(value): + return fmt.Errorf("field %s contains characters not permitted by baseline validation", fieldName) + default: + return nil + } +} + +func checkStringSliceFormat(fieldName string, value []string, maxLength, maxStringLength int) error { //nolint:unparam + if len(value) > maxLength { + return fmt.Errorf("field %s with array/slice length of %d exceeds max length of %d", fieldName, len(value), maxLength) + } + for _, s := range value { + if err := checkStringFormat(fieldName, s, maxStringLength); err != nil { + return err + } + } + return nil +} + +func checkByteSliceFormat(fieldName string, value []byte, maxLength int) error { + switch { + case len(value) > maxLength: + return fmt.Errorf("field %s with length of %d exceeds max length of %d", fieldName, len(value), maxLength) + default: + return nil + } +} + +func checkIPFormat(fieldName string, value net.IP) error { + // Check if there is an IP address. + if value == nil { + return nil + } + + switch { + case len(value) != 4 && len(value) != 16: + return fmt.Errorf("field %s has an invalid length of %d for an IP address", fieldName, len(value)) + case netutils.GetIPScope(value) == netutils.Invalid: + return fmt.Errorf("field %s holds an invalid IP address: %s", fieldName, value) + default: + return nil + } +} diff --git a/spn/hub/format_test.go b/spn/hub/format_test.go new file mode 100644 index 00000000..1e6bf7e2 --- /dev/null +++ b/spn/hub/format_test.go @@ -0,0 +1,81 @@ +package hub + +import ( + "fmt" + "net" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckStringFormat(t *testing.T) { + t.Parallel() + + testSet := map[string]bool{ + // Printable ASCII (character code 32-127) + " ": true, "!": true, `"`: false, "#": true, "$": false, "%": false, "&": false, "'": false, + "(": true, ")": true, "*": true, "+": true, ",": true, "-": true, ".": true, "/": true, + "0": true, "1": true, "2": true, "3": true, "4": true, "5": true, "6": true, "7": true, + "8": true, "9": true, ":": true, ";": false, "<": false, "=": true, ">": false, "?": true, + "@": true, "A": true, "B": true, "C": true, "D": true, "E": true, "F": true, "G": true, + "H": true, "I": true, "J": true, "K": true, "L": true, "M": true, "N": true, "O": true, + "P": true, "Q": true, "R": true, "S": true, "T": true, "U": true, "V": true, "W": true, + "X": true, "Y": true, "Z": true, "[": true, `\`: false, "]": true, "^": true, "_": true, + "`": false, "a": true, "b": true, "c": true, "d": true, "e": true, "f": true, "g": true, + "h": true, "i": true, "j": true, "k": true, "l": true, "m": true, "n": true, "o": true, + "p": true, "q": true, "r": true, "s": true, "t": true, "u": true, "v": true, "w": true, + "x": true, "y": true, "z": true, "{": true, "|": true, "}": true, "~": true, + // Not testing for DELETE character. + + // Extended ASCII (character code 128-255) + "€": false, "‚": false, "ƒ": false, "„": false, "…": false, "†": false, "‡": false, "ˆ": false, + "‰": false, "Š": true, "‹": false, "Œ": true, "Ž": true, "‘": false, "’": false, "“": false, + "”": false, "•": false, "–": false, "—": false, "˜": false, "™": false, "š": true, "›": false, + "œ": true, "ž": true, "Ÿ": true, "¡": true, "¢": false, "£": false, "¤": false, "¥": false, + "¦": false, "§": false, "¨": false, "©": false, "ª": false, "«": false, "¬": false, "®": false, + "¯": false, "°": false, "±": false, "²": false, "³": false, "´": false, "µ": false, "¶": false, + "·": false, "¸": false, "¹": false, "º": false, "»": false, "¼": false, "½": false, "¾": false, + "¿": true, "À": true, "Á": true, "Â": true, "Ã": true, "Ä": true, "Å": true, "Æ": true, + "Ç": true, "È": true, "É": true, "Ê": true, "Ë": true, "Ì": true, "Í": true, "Î": true, + "Ï": true, "Ð": true, "Ñ": true, "Ò": true, "Ó": true, "Ô": true, "Õ": true, "Ö": true, + "×": false, "Ø": true, "Ù": true, "Ú": true, "Û": true, "Ü": true, "Ý": true, "Þ": true, + "ß": true, "à": true, "á": true, "â": true, "ã": true, "ä": true, "å": true, "æ": true, + "ç": true, "è": true, "é": true, "ê": true, "ë": true, "ì": true, "í": true, "î": true, + "ï": true, "ð": true, "ñ": true, "ò": true, "ó": true, "ô": true, "õ": true, "ö": true, + "÷": false, "ø": true, "ù": true, "ú": true, "û": true, "ü": true, "ý": true, "þ": true, + "ÿ": true, + } + + for testCharacter, isPermitted := range testSet { + if isPermitted { + require.NoError(t, checkStringFormat(fmt.Sprintf("test character %q", testCharacter), testCharacter, 3)) + } else { + require.Error(t, checkStringFormat(fmt.Sprintf("test character %q", testCharacter), testCharacter, 3)) + } + } +} + +func TestCheckIPFormat(t *testing.T) { + t.Parallel() + + // IPv4 + require.NoError(t, checkIPFormat("test IP 1.1.1.1", net.IPv4(1, 1, 1, 1))) + require.NoError(t, checkIPFormat("test IP 192.168.1.1", net.IPv4(192, 168, 1, 1))) + require.Error(t, checkIPFormat("test IP 255.0.0.1", net.IPv4(255, 0, 0, 1))) + + // IPv6 + require.NoError(t, checkIPFormat("test IP ::1", net.ParseIP("::1"))) + require.NoError(t, checkIPFormat("test IP 2606:4700:4700::1111", net.ParseIP("2606:4700:4700::1111"))) + + // Invalid + require.Error(t, checkIPFormat("test IP with length 3", net.IP([]byte{0, 0, 0}))) + require.Error(t, checkIPFormat("test IP with length 5", net.IP([]byte{0, 0, 0, 0, 0}))) + require.Error(t, checkIPFormat( + "test IP with length 15", + net.IP([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}), + )) + require.Error(t, checkIPFormat( + "test IP with length 17", + net.IP([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}), + )) +} diff --git a/spn/hub/hub.go b/spn/hub/hub.go new file mode 100644 index 00000000..efc34cd0 --- /dev/null +++ b/spn/hub/hub.go @@ -0,0 +1,435 @@ +package hub + +import ( + "fmt" + "net" + "sync" + "time" + + "golang.org/x/exp/slices" + + "github.com/safing/jess" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/profile/endpoints" +) + +// Scope is the network scope a Hub can be in. +type Scope uint8 + +const ( + // ScopeInvalid defines an invalid scope. + ScopeInvalid Scope = 0 + + // ScopeLocal identifies local Hubs. + ScopeLocal Scope = 1 + + // ScopePublic identifies public Hubs. + ScopePublic Scope = 2 + + // ScopeTest identifies Hubs for testing. + ScopeTest Scope = 0xFF +) + +const ( + obsoleteValidAfter = 30 * 24 * time.Hour + obsoleteInvalidAfter = 7 * 24 * time.Hour +) + +// MsgType defines the message type. +type MsgType string + +// Message Types. +const ( + MsgTypeAnnouncement = "announcement" + MsgTypeStatus = "status" +) + +// Hub represents a network node in the SPN. +type Hub struct { //nolint:maligned + sync.Mutex + record.Base + + ID string + PublicKey *jess.Signet + Map string + + Info *Announcement + Status *Status + + Measurements *Measurements + measurementsInitialized bool + + FirstSeen time.Time + VerifiedIPs bool + InvalidInfo bool + InvalidStatus bool +} + +// Announcement is the main message type to publish Hub Information. This only changes if updated manually. +type Announcement struct { + // Primary Key + // hash of public key + // must be checked if it matches the public key + ID string `cbor:"i"` // via jess.LabeledHash + + // PublicKey *jess.Signet + // PublicKey // if not part of signature + // Signature *jess.Letter + Timestamp int64 `cbor:"t"` // Unix timestamp in seconds + + // Node Information + Name string `cbor:"n"` // name of the node + Group string `cbor:"g,omitempty" json:",omitempty"` // person or organisation, who is in control of the node (should be same for all nodes of this person or organisation) + ContactAddress string `cbor:"ca,omitempty" json:",omitempty"` // contact possibility (recommended, but optional) + ContactService string `cbor:"cs,omitempty" json:",omitempty"` // type of service of the contact address, if not email + + // currently unused, but collected for later use + Hosters []string `cbor:"ho,omitempty" json:",omitempty"` // hoster supply chain (reseller, hosting provider, datacenter operator, ...) + Datacenter string `cbor:"dc,omitempty" json:",omitempty"` // datacenter will be bullshit checked + // Format: CC-COMPANY-INTERNALCODE + // Eg: DE-Hetzner-FSN1-DC5 + + // Network Location and Access + // If node is behind NAT (or similar), IP addresses must be configured + IPv4 net.IP `cbor:"ip4,omitempty" json:",omitempty"` // must be global and accessible + IPv6 net.IP `cbor:"ip6,omitempty" json:",omitempty"` // must be global and accessible + Transports []string `cbor:"tp,omitempty" json:",omitempty"` + // { + // "spn:17", + // "smtp:25", // also support "smtp://:25 + // "smtp:587", + // "imap:143", + // "http:80", + // "http://example.com:80", // HTTP (based): use full path for request + // "https:443", + // "ws:80", + // "wss://example.com:443/spn", + // } // protocols with metadata + parsedTransports []*Transport + + // Policies - default permit + Entry []string `cbor:"pi,omitempty" json:",omitempty"` + entryPolicy endpoints.Endpoints + // {"+ ", "- *"} + Exit []string `cbor:"po,omitempty" json:",omitempty"` + exitPolicy endpoints.Endpoints + // {"- * TCP/25", "- US"} + + // Flags holds flags that signify special states. + Flags []string `cbor:"f,omitempty" json:",omitempty"` +} + +// Copy returns a deep copy of the Announcement. +func (a *Announcement) Copy() *Announcement { + return &Announcement{ + ID: a.ID, + Timestamp: a.Timestamp, + Name: a.Name, + ContactAddress: a.ContactAddress, + ContactService: a.ContactService, + Hosters: slices.Clone(a.Hosters), + Datacenter: a.Datacenter, + IPv4: a.IPv4, + IPv6: a.IPv6, + Transports: slices.Clone(a.Transports), + parsedTransports: slices.Clone(a.parsedTransports), + Entry: slices.Clone(a.Entry), + entryPolicy: slices.Clone(a.entryPolicy), + Exit: slices.Clone(a.Exit), + exitPolicy: slices.Clone(a.exitPolicy), + Flags: slices.Clone(a.Flags), + } +} + +// GetInfo returns the hub info. +func (h *Hub) GetInfo() *Announcement { + h.Lock() + defer h.Unlock() + + return h.Info +} + +// GetStatus returns the hub status. +func (h *Hub) GetStatus() *Status { + h.Lock() + defer h.Unlock() + + return h.Status +} + +// GetMeasurements returns the hub measurements. +// This method should always be used instead of direct access. +func (h *Hub) GetMeasurements() *Measurements { + h.Lock() + defer h.Unlock() + + return h.GetMeasurementsWithLockedHub() +} + +// GetMeasurementsWithLockedHub returns the hub measurements. +// The caller must hold the lock to Hub. +// This method should always be used instead of direct access. +func (h *Hub) GetMeasurementsWithLockedHub() *Measurements { + if !h.measurementsInitialized { + h.Measurements = getSharedMeasurements(h.ID, h.Measurements) + h.Measurements.check() + h.measurementsInitialized = true + } + + return h.Measurements +} + +// Verified return whether the Hub has been verified. +func (h *Hub) Verified() bool { + h.Lock() + defer h.Unlock() + + return h.VerifiedIPs +} + +// String returns a human-readable representation of the Hub. +func (h *Hub) String() string { + h.Lock() + defer h.Unlock() + + return "" +} + +// StringWithoutLocking returns a human-readable representation of the Hub without locking it. +func (h *Hub) StringWithoutLocking() string { + return "" +} + +// Name returns a human-readable version of a Hub's name. This name will likely consist of two parts: the given name and the ending of the ID to make it unique. +func (h *Hub) Name() string { + h.Lock() + defer h.Unlock() + + return h.getName() +} + +func (h *Hub) getName() string { + // Check for a short ID that is sometimes used for testing. + if len(h.ID) < 8 { + return h.ID + } + + shortenedID := h.ID[len(h.ID)-8:len(h.ID)-4] + + "-" + + h.ID[len(h.ID)-4:] + + // Be more careful, as the Hub name is user input. + switch { + case h.Info.Name == "": + return shortenedID + case len(h.Info.Name) > 16: + return h.Info.Name[:16] + " " + shortenedID + default: + return h.Info.Name + " " + shortenedID + } +} + +// Obsolete returns if the Hub is obsolete and may be deleted. +func (h *Hub) Obsolete() bool { + h.Lock() + defer h.Unlock() + + // Check if Hub is valid. + var valid bool + switch { + case h.InvalidInfo: + case h.InvalidStatus: + case h.HasFlag(FlagOffline): + // Treat offline as invalid. + default: + valid = true + } + + // Check when Hub was last seen. + lastSeen := h.FirstSeen + if h.Status.Timestamp != 0 { + lastSeen = time.Unix(h.Status.Timestamp, 0) + } + + // Check if Hub is obsolete. + if valid { + return time.Now().Add(-obsoleteValidAfter).After(lastSeen) + } + return time.Now().Add(-obsoleteInvalidAfter).After(lastSeen) +} + +// HasFlag returns whether the Announcement or Status has the given flag set. +func (h *Hub) HasFlag(flagName string) bool { + switch { + case h.Status != nil && slices.Contains[[]string, string](h.Status.Flags, flagName): + return true + case h.Info != nil && slices.Contains[[]string, string](h.Info.Flags, flagName): + return true + } + return false +} + +// Equal returns whether the given Announcements are equal. +func (a *Announcement) Equal(b *Announcement) bool { + switch { + case a == nil || b == nil: + return false + case a.ID != b.ID: + return false + case a.Timestamp != b.Timestamp: + return false + case a.Name != b.Name: + return false + case a.ContactAddress != b.ContactAddress: + return false + case a.ContactService != b.ContactService: + return false + case !equalStringSlice(a.Hosters, b.Hosters): + return false + case a.Datacenter != b.Datacenter: + return false + case !a.IPv4.Equal(b.IPv4): + return false + case !a.IPv6.Equal(b.IPv6): + return false + case !equalStringSlice(a.Transports, b.Transports): + return false + case !equalStringSlice(a.Entry, b.Entry): + return false + case !equalStringSlice(a.Exit, b.Exit): + return false + case !equalStringSlice(a.Flags, b.Flags): + return false + default: + return true + } +} + +// validateFormatting check if all values conform to the basic format. +func (a *Announcement) validateFormatting() error { + if err := checkStringFormat("ID", a.ID, 255); err != nil { + return err + } + if err := checkStringFormat("Name", a.Name, 32); err != nil { + return err + } + if err := checkStringFormat("Group", a.Group, 32); err != nil { + return err + } + if err := checkStringFormat("ContactAddress", a.ContactAddress, 255); err != nil { + return err + } + if err := checkStringFormat("ContactService", a.ContactService, 255); err != nil { + return err + } + if err := checkStringSliceFormat("Hosters", a.Hosters, 255, 255); err != nil { + return err + } + if err := checkStringFormat("Datacenter", a.Datacenter, 255); err != nil { + return err + } + if err := checkIPFormat("IPv4", a.IPv4); err != nil { + return err + } + if err := checkIPFormat("IPv6", a.IPv6); err != nil { + return err + } + if err := checkStringSliceFormat("Transports", a.Transports, 255, 255); err != nil { + return err + } + if err := checkStringSliceFormat("Entry", a.Entry, 255, 255); err != nil { + return err + } + if err := checkStringSliceFormat("Exit", a.Exit, 255, 255); err != nil { + return err + } + if err := checkStringSliceFormat("Flags", a.Flags, 16, 32); err != nil { + return err + } + return nil +} + +// Prepare prepares the announcement by parsing policies and transports. +// If fields are already parsed, they will only be parsed again, when force is set to true. +func (a *Announcement) prepare(force bool) error { + var err error + + // Parse policies. + if len(a.entryPolicy) == 0 || force { + if a.entryPolicy, err = endpoints.ParseEndpoints(a.Entry); err != nil { + return fmt.Errorf("failed to parse entry policy: %w", err) + } + } + if len(a.exitPolicy) == 0 || force { + if a.exitPolicy, err = endpoints.ParseEndpoints(a.Exit); err != nil { + return fmt.Errorf("failed to parse exit policy: %w", err) + } + } + + // Parse transports. + if len(a.parsedTransports) == 0 || force { + parsed, errs := ParseTransports(a.Transports) + // Log parsing warnings. + for _, err := range errs { + log.Warningf("hub: Hub %s (%s) has configured an %s", a.Name, a.ID, err) + } + // Check if there are any valid transports. + if len(parsed) == 0 { + return ErrMissingTransports + } + a.parsedTransports = parsed + } + + return nil +} + +// EntryPolicy returns the Hub's entry policy. +func (a *Announcement) EntryPolicy() endpoints.Endpoints { + return a.entryPolicy +} + +// ExitPolicy returns the Hub's exit policy. +func (a *Announcement) ExitPolicy() endpoints.Endpoints { + return a.exitPolicy +} + +// ParsedTransports returns the Hub's parsed transports. +func (a *Announcement) ParsedTransports() []*Transport { + return a.parsedTransports +} + +// HasFlag returns whether the Announcement has the given flag set. +func (a *Announcement) HasFlag(flagName string) bool { + return slices.Contains[[]string, string](a.Flags, flagName) +} + +// String returns the string representation of the scope. +func (s Scope) String() string { + switch s { + case ScopeInvalid: + return "invalid" + case ScopeLocal: + return "local" + case ScopePublic: + return "public" + case ScopeTest: + return "test" + default: + return "unknown" + } +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/spn/hub/hub_test.go b/spn/hub/hub_test.go new file mode 100644 index 00000000..70cc5b16 --- /dev/null +++ b/spn/hub/hub_test.go @@ -0,0 +1,79 @@ +package hub + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safing/portbase/modules" + _ "github.com/safing/portmaster/service/core/base" + "github.com/safing/portmaster/service/core/pmtesting" +) + +func TestMain(m *testing.M) { + // TODO: We need the database module, so maybe set up a module for this package. + module := modules.Register("hub", nil, nil, nil, "base") + pmtesting.TestMain(m, module) +} + +func TestEquality(t *testing.T) { + t.Parallel() + + // empty match + a := &Announcement{} + assert.True(t, a.Equal(a), "should match itself") //nolint:gocritic // This is a test. + + // full match + a = &Announcement{ + ID: "a", + Timestamp: 1, + Name: "a", + ContactAddress: "a", + ContactService: "a", + Hosters: []string{"a", "b"}, + Datacenter: "a", + IPv4: net.IPv4(1, 2, 3, 4), + IPv6: net.ParseIP("::1"), + Transports: []string{"a", "b"}, + Entry: []string{"a", "b"}, + Exit: []string{"a", "b"}, + } + assert.True(t, a.Equal(a), "should match itself") //nolint:gocritic // This is a test. + + // no match + b := &Announcement{ID: "b"} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Timestamp: 2} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Name: "b"} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{ContactAddress: "b"} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{ContactService: "b"} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Hosters: []string{"b", "c"}} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Datacenter: "b"} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{IPv4: net.IPv4(1, 2, 3, 5)} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{IPv6: net.ParseIP("::2")} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Transports: []string{"b", "c"}} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Entry: []string{"b", "c"}} + assert.False(t, a.Equal(b), "should not match") + b = &Announcement{Exit: []string{"b", "c"}} + assert.False(t, a.Equal(b), "should not match") +} + +func TestStringify(t *testing.T) { + t.Parallel() + + assert.Equal(t, "", (&Hub{ID: "abcdefg", Info: &Announcement{}}).String()) + assert.Equal(t, "", (&Hub{ID: "abcdefgh", Info: &Announcement{}}).String()) + assert.Equal(t, "", (&Hub{ID: "abcdefghi", Info: &Announcement{}}).String()) + assert.Equal(t, "", (&Hub{ID: "abcdefghi", Info: &Announcement{Name: "Franz"}}).String()) + assert.Equal(t, "", (&Hub{ID: "abcdefghi", Info: &Announcement{Name: "AProbablyAutoGeneratedName"}}).String()) +} diff --git a/spn/hub/intel.go b/spn/hub/intel.go new file mode 100644 index 00000000..8bc505ed --- /dev/null +++ b/spn/hub/intel.go @@ -0,0 +1,191 @@ +package hub + +import ( + "errors" + "fmt" + "net" + + "github.com/ghodss/yaml" + + "github.com/safing/jess/lhash" + "github.com/safing/portmaster/service/profile/endpoints" +) + +// Intel holds a collection of various security related data collections on Hubs. +type Intel struct { + // BootstrapHubs is list of transports that also contain an IP and the Hub's ID. + BootstrapHubs []string + + // Hubs holds intel regarding specific Hubs. + Hubs map[string]*HubIntel + + // AdviseOnlyTrustedHubs advises to only use trusted Hubs regardless of intended purpose. + AdviseOnlyTrustedHubs bool + // AdviseOnlyTrustedHomeHubs advises to only use trusted Hubs for Home Hubs. + AdviseOnlyTrustedHomeHubs bool + // AdviseOnlyTrustedDestinationHubs advises to only use trusted Hubs for Destination Hubs. + AdviseOnlyTrustedDestinationHubs bool + + // Hub Advisories advise on the usage of Hubs and take the form of Endpoint Lists that match on both IPv4 and IPv6 addresses and their related data. + + // HubAdvisory always affects all Hubs. + HubAdvisory []string + // HomeHubAdvisory is only taken into account when selecting a Home Hub. + HomeHubAdvisory []string + // DestinationHubAdvisory is only taken into account when selecting a Destination Hub. + DestinationHubAdvisory []string + + // Regions defines regions to assist network optimization. + Regions []*RegionConfig + + // VirtualNetworks holds network configurations for virtual cloud networks. + VirtualNetworks []*VirtualNetworkConfig + + parsed *ParsedIntel +} + +// HubIntel holds Hub-related data. +type HubIntel struct { //nolint:golint + // Trusted specifies if the Hub is specially designated for more sensitive tasks, such as handling unencrypted traffic. + Trusted bool + + // Discontinued specifies if the Hub has been discontinued and should be marked as offline and removed. + Discontinued bool + + // VerifiedOwner holds the name of the verified owner / operator of the Hub. + VerifiedOwner string + + // Override is used to override certain Hub information. + Override *InfoOverride +} + +// RegionConfig holds the configuration of a region. +type RegionConfig struct { + // ID is the internal identifier of the region. + ID string + // Name is a human readable name of the region. + Name string + // MemberPolicy specifies a list for including members. + MemberPolicy []string + + // RegionalMinLanes specifies how many lanes other regions should build + // to this region. + RegionalMinLanes int + // RegionalMinLanesPerHub specifies how many lanes other regions should + // build to this region, per Hub in this region. + // This value will usually be below one. + RegionalMinLanesPerHub float64 + // RegionalMaxLanesOnHub specifies how many lanes from or to another region may be + // built on one Hub per region. + RegionalMaxLanesOnHub int + + // SatelliteMinLanes specifies how many lanes satellites (Hubs without + // region) should build to this region. + SatelliteMinLanes int + // SatelliteMinLanesPerHub specifies how many lanes satellites (Hubs without + // region) should build to this region, per Hub in this region. + // This value will usually be below one. + SatelliteMinLanesPerHub float64 + + // InternalMinLanesOnHub specifies how many lanes every Hub should create + // within the region at minimum. + InternalMinLanesOnHub int + // InternalMaxHops specifies the max hop constraint for internally optimizing + // the region. + InternalMaxHops int +} + +// VirtualNetworkConfig holds configuration of a virtual network that binds multiple Hubs together. +type VirtualNetworkConfig struct { + // Name is a human readable name of the virtual network. + Name string + // Force forces the use of the mapped IP addresses after the Hub's IPs have been verified. + Force bool + // Mapping maps Hub IDs to internal IP addresses. + Mapping map[string]net.IP +} + +// ParsedIntel holds a collection of parsed intel data. +type ParsedIntel struct { + // HubAdvisory always affects all Hubs. + HubAdvisory endpoints.Endpoints + + // HomeHubAdvisory is only taken into account when selecting a Home Hub. + HomeHubAdvisory endpoints.Endpoints + + // DestinationHubAdvisory is only taken into account when selecting a Destination Hub. + DestinationHubAdvisory endpoints.Endpoints +} + +// Parsed returns the collection of parsed intel data. +func (i *Intel) Parsed() *ParsedIntel { + return i.parsed +} + +// ParseIntel parses Hub intelligence data. +func ParseIntel(data []byte) (*Intel, error) { + // Load data into struct. + intel := &Intel{} + err := yaml.Unmarshal(data, intel) + if err != nil { + return nil, fmt.Errorf("failed to parse data: %w", err) + } + + // Parse all endpoint lists. + err = intel.ParseAdvisories() + if err != nil { + return nil, err + } + + return intel, nil +} + +// ParseAdvisories parses all advisory endpoint lists. +func (i *Intel) ParseAdvisories() (err error) { + i.parsed = &ParsedIntel{} + + i.parsed.HubAdvisory, err = endpoints.ParseEndpoints(i.HubAdvisory) + if err != nil { + return fmt.Errorf("failed to parse HubAdvisory list: %w", err) + } + + i.parsed.HomeHubAdvisory, err = endpoints.ParseEndpoints(i.HomeHubAdvisory) + if err != nil { + return fmt.Errorf("failed to parse HomeHubAdvisory list: %w", err) + } + + i.parsed.DestinationHubAdvisory, err = endpoints.ParseEndpoints(i.DestinationHubAdvisory) + if err != nil { + return fmt.Errorf("failed to parse DestinationHubAdvisory list: %w", err) + } + + return nil +} + +// ParseBootstrapHub parses a bootstrap hub. +func ParseBootstrapHub(bootstrapTransport string) (t *Transport, hubID string, hubIP net.IP, err error) { + // Parse transport and check Hub ID. + t, err = ParseTransport(bootstrapTransport) + if err != nil { + return nil, "", nil, fmt.Errorf("failed to parse transport: %w", err) + } + if t.Option == "" { + return nil, "", nil, errors.New("missing hub ID in URL fragment") + } + if _, err := lhash.FromBase58(t.Option); err != nil { + return nil, "", nil, fmt.Errorf("hub ID is invalid: %w", err) + } + + // Parse IP address from transport. + ip := net.ParseIP(t.Domain) + if ip == nil { + return nil, "", nil, errors.New("invalid IP address (domains are not supported for bootstrapping)") + } + + // Clean up transport for hub info. + id := t.Option + t.Domain = "" + t.Option = "" + + return t, id, ip, nil +} diff --git a/spn/hub/intel_override.go b/spn/hub/intel_override.go new file mode 100644 index 00000000..0fa7f29c --- /dev/null +++ b/spn/hub/intel_override.go @@ -0,0 +1,17 @@ +package hub + +import "github.com/safing/portmaster/service/intel/geoip" + +// InfoOverride holds data to overide hub info information. +type InfoOverride struct { + // ContinentCode overrides the continent code of the geoip data. + ContinentCode string + // CountryCode overrides the country code of the geoip data. + CountryCode string + // Coordinates overrides the geo coordinates code of the geoip data. + Coordinates *geoip.Coordinates + // ASN overrides the Autonomous System Number of the geoip data. + ASN uint + // ASOrg overrides the Autonomous System Organization of the geoip data. + ASOrg string +} diff --git a/spn/hub/measurements.go b/spn/hub/measurements.go new file mode 100644 index 00000000..135a67c9 --- /dev/null +++ b/spn/hub/measurements.go @@ -0,0 +1,231 @@ +package hub + +import ( + "sync" + "time" + + "github.com/tevino/abool" +) + +// MaxCalculatedCost specifies the max calculated cost to be used for an unknown high cost. +const MaxCalculatedCost = 1000000 + +// Measurements holds various measurements relating to a Hub. +// Fields may not be accessed directly. +type Measurements struct { + sync.Mutex + + // Latency designates the latency between these Hubs. + // It is specified in nanoseconds. + Latency time.Duration + // LatencyMeasuredAt holds when the latency was measured. + LatencyMeasuredAt time.Time + + // Capacity designates the available bandwidth between these Hubs. + // It is specified in bit/s. + Capacity int + // CapacityMeasuredAt holds when the capacity measurement expires. + CapacityMeasuredAt time.Time + + // CalculatedCost stores the calculated cost for direct access. + // It is not set automatically, but needs to be set when needed. + CalculatedCost float32 + + // GeoProximity stores an approximation of the geolocation proximity. + // The value is between 0 (other side of the world) and 100 (same location). + GeoProximity float32 + + // persisted holds whether the Measurements have been persisted to the + // database. + persisted *abool.AtomicBool +} + +// NewMeasurements returns a new measurements struct. +func NewMeasurements() *Measurements { + m := &Measurements{ + CalculatedCost: MaxCalculatedCost, // Push to back when sorting without data. + } + m.check() + return m +} + +// Copy returns a copy of the measurements. +func (m *Measurements) Copy() *Measurements { + copied := &Measurements{ + Latency: m.Latency, + LatencyMeasuredAt: m.LatencyMeasuredAt, + Capacity: m.Capacity, + CapacityMeasuredAt: m.CapacityMeasuredAt, + CalculatedCost: m.CalculatedCost, + } + copied.check() + return copied +} + +// Check checks if the Measurements are properly initialized and ready to use. +func (m *Measurements) check() { + if m == nil { + return + } + + m.Lock() + defer m.Unlock() + + if m.persisted == nil { + m.persisted = abool.NewBool(true) + } +} + +// IsPersisted return whether changes to the measurements have been persisted. +func (m *Measurements) IsPersisted() bool { + return m.persisted.IsSet() +} + +// Valid returns whether there is a valid value . +func (m *Measurements) Valid() bool { + m.Lock() + defer m.Unlock() + + switch { + case m.Latency == 0: + // Latency is not set. + case m.Capacity == 0: + // Capacity is not set. + case m.CalculatedCost == 0: + // CalculatedCost is not set. + case m.CalculatedCost == MaxCalculatedCost: + // CalculatedCost is set to static max value. + default: + return true + } + + return false +} + +// Expired returns whether any of the measurements has expired - calculated +// with the given TTL. +func (m *Measurements) Expired(ttl time.Duration) bool { + expiry := time.Now().Add(-ttl) + + m.Lock() + defer m.Unlock() + + switch { + case expiry.After(m.LatencyMeasuredAt): + return true + case expiry.After(m.CapacityMeasuredAt): + return true + default: + return false + } +} + +// SetLatency sets the latency to the given value. +func (m *Measurements) SetLatency(latency time.Duration) { + m.Lock() + defer m.Unlock() + + m.Latency = latency + m.LatencyMeasuredAt = time.Now() + m.persisted.UnSet() +} + +// GetLatency returns the latency and when it expires. +func (m *Measurements) GetLatency() (latency time.Duration, measuredAt time.Time) { + m.Lock() + defer m.Unlock() + + return m.Latency, m.LatencyMeasuredAt +} + +// SetCapacity sets the capacity to the given value. +// The capacity is measued in bit/s. +func (m *Measurements) SetCapacity(capacity int) { + m.Lock() + defer m.Unlock() + + m.Capacity = capacity + m.CapacityMeasuredAt = time.Now() + m.persisted.UnSet() +} + +// GetCapacity returns the capacity and when it expires. +// The capacity is measued in bit/s. +func (m *Measurements) GetCapacity() (capacity int, measuredAt time.Time) { + m.Lock() + defer m.Unlock() + + return m.Capacity, m.CapacityMeasuredAt +} + +// SetCalculatedCost sets the calculated cost to the given value. +// The calculated cost is not set automatically, but needs to be set when needed. +func (m *Measurements) SetCalculatedCost(cost float32) { + m.Lock() + defer m.Unlock() + + m.CalculatedCost = cost + m.persisted.UnSet() +} + +// GetCalculatedCost returns the calculated cost. +// The calculated cost is not set automatically, but needs to be set when needed. +func (m *Measurements) GetCalculatedCost() (cost float32) { + if m == nil { + return MaxCalculatedCost + } + + m.Lock() + defer m.Unlock() + + return m.CalculatedCost +} + +// SetGeoProximity sets the geolocation proximity to the given value. +func (m *Measurements) SetGeoProximity(geoProximity float32) { + m.Lock() + defer m.Unlock() + + m.GeoProximity = geoProximity + m.persisted.UnSet() +} + +// GetGeoProximity returns the geolocation proximity. +func (m *Measurements) GetGeoProximity() (geoProximity float32) { + if m == nil { + return 0 + } + + m.Lock() + defer m.Unlock() + + return m.GeoProximity +} + +var ( + measurementsRegistry = make(map[string]*Measurements) + measurementsRegistryLock sync.Mutex +) + +func getSharedMeasurements(hubID string, existing *Measurements) *Measurements { + measurementsRegistryLock.Lock() + defer measurementsRegistryLock.Unlock() + + // 1. Check registry and return shared measurements. + m, ok := measurementsRegistry[hubID] + if ok { + return m + } + + // 2. Use existing and make it shared, if available. + if existing != nil { + existing.check() + measurementsRegistry[hubID] = existing + return existing + } + + // 3. Create new measurements. + m = NewMeasurements() + measurementsRegistry[hubID] = m + return m +} diff --git a/spn/hub/status.go b/spn/hub/status.go new file mode 100644 index 00000000..0d5c4808 --- /dev/null +++ b/spn/hub/status.go @@ -0,0 +1,308 @@ +package hub + +import ( + "errors" + "fmt" + "sort" + "time" + + "golang.org/x/exp/slices" + + "github.com/safing/jess" +) + +// VersionOffline is a special version used to signify that the Hub has gone offline. +// This is depracated, please use FlagOffline instead. +const VersionOffline = "offline" + +// Status Flags. +const ( + // FlagNetError signifies that the Hub reports a network connectivity failure or impairment. + FlagNetError = "net-error" + + // FlagOffline signifies that the Hub has gone offline by itself. + FlagOffline = "offline" + + // FlagAllowUnencrypted signifies that the Hub is available to handle unencrypted connections. + FlagAllowUnencrypted = "allow-unencrypted" +) + +// Status is the message type used to update changing Hub Information. Changes are made automatically. +type Status struct { + Timestamp int64 `cbor:"t"` + + // Version holds the current software version of the Hub. + Version string `cbor:"v"` + + // Routing Information + Keys map[string]*Key `cbor:"k,omitempty" json:",omitempty"` // public keys (with type) + Lanes []*Lane `cbor:"c,omitempty" json:",omitempty"` // Connections to other Hubs. + + // Status Information + // Load describes max(CPU, Memory) in percent, averaged over at least 15 + // minutes. Load is published in fixed steps only. + Load int `cbor:"l,omitempty" json:",omitempty"` + + // Flags holds flags that signify special states. + Flags []string `cbor:"f,omitempty" json:",omitempty"` +} + +// Key represents a semi-ephemeral public key used for 0-RTT connection establishment. +type Key struct { + Scheme string + Key []byte + Expires int64 +} + +// Lane represents a connection to another Hub. +type Lane struct { + // ID is the Hub ID of the peer. + ID string + + // Capacity designates the available bandwidth between these Hubs. + // It is specified in bit/s. + Capacity int + + // Lateny designates the latency between these Hubs. + // It is specified in nanoseconds. + Latency time.Duration +} + +// Copy returns a deep copy of the Status. +func (s *Status) Copy() *Status { + newStatus := &Status{ + Timestamp: s.Timestamp, + Version: s.Version, + Lanes: slices.Clone(s.Lanes), + Load: s.Load, + Flags: slices.Clone(s.Flags), + } + // Copy map. + newStatus.Keys = make(map[string]*Key, len(s.Keys)) + for k, v := range s.Keys { + newStatus.Keys[k] = v + } + return newStatus +} + +// SelectSignet selects the public key to use for initiating connections to that Hub. +func (h *Hub) SelectSignet() *jess.Signet { + h.Lock() + defer h.Unlock() + + // Return no Signet if we don't have a Status. + if h.Status == nil { + return nil + } + + // TODO: select key based on preferred alg? + now := time.Now().Unix() + for id, key := range h.Status.Keys { + if now < key.Expires { + return &jess.Signet{ + ID: id, + Scheme: key.Scheme, + Key: key.Key, + Public: true, + } + } + } + + return nil +} + +// GetSignet returns the public key identified by the given ID from the Hub Status. +func (h *Hub) GetSignet(id string, recipient bool) (*jess.Signet, error) { + h.Lock() + defer h.Unlock() + + // check if public key is being requested + if !recipient { + return nil, jess.ErrSignetNotFound + } + // check if ID exists + key, ok := h.Status.Keys[id] + if !ok { + return nil, jess.ErrSignetNotFound + } + // transform and return + return &jess.Signet{ + ID: id, + Scheme: key.Scheme, + Key: key.Key, + Public: true, + }, nil +} + +// AddLane adds a new Lane to the Hub Status. +func (h *Hub) AddLane(newLane *Lane) error { + h.Lock() + defer h.Unlock() + + // validity check + if h.Status == nil { + return ErrMissingInfo + } + + // check if duplicate + for _, lane := range h.Status.Lanes { + if newLane.ID == lane.ID { + return errors.New("lane already exists") + } + } + + // add + h.Status.Lanes = append(h.Status.Lanes, newLane) + return nil +} + +// RemoveLane removes a Lane from the Hub Status. +func (h *Hub) RemoveLane(hubID string) error { + h.Lock() + defer h.Unlock() + + // validity check + if h.Status == nil { + return ErrMissingInfo + } + + for key, lane := range h.Status.Lanes { + if lane.ID == hubID { + h.Status.Lanes = append(h.Status.Lanes[:key], h.Status.Lanes[key+1:]...) + break + } + } + + return nil +} + +// GetLaneTo returns the lane to the given Hub, if it exists. +func (h *Hub) GetLaneTo(hubID string) *Lane { + h.Lock() + defer h.Unlock() + + // validity check + if h.Status == nil { + return nil + } + + for _, lane := range h.Status.Lanes { + if lane.ID == hubID { + return lane + } + } + + return nil +} + +// Equal returns whether the Lane is equal to the given one. +func (l *Lane) Equal(other *Lane) bool { + switch { + case l == nil || other == nil: + return false + case l.ID != other.ID: + return false + case l.Capacity != other.Capacity: + return false + case l.Latency != other.Latency: + return false + } + return true +} + +// validateFormatting check if all values conform to the basic format. +func (s *Status) validateFormatting() error { + // public keys + if len(s.Keys) > 255 { + return fmt.Errorf("field Keys with array/slice length of %d exceeds max length of %d", len(s.Keys), 255) + } + for keyID, key := range s.Keys { + if err := checkStringFormat("Keys#ID", keyID, 255); err != nil { + return err + } + if err := checkStringFormat("Keys.Scheme", key.Scheme, 255); err != nil { + return err + } + if err := checkByteSliceFormat("Keys.Key", key.Key, 1024); err != nil { + return err + } + } + + // connections + if len(s.Lanes) > 255 { + return fmt.Errorf("field Lanes with array/slice length of %d exceeds max length of %d", len(s.Lanes), 255) + } + for _, lanes := range s.Lanes { + if err := checkStringFormat("Lanes.ID", lanes.ID, 255); err != nil { + return err + } + } + + // Flags + if err := checkStringSliceFormat("Flags", s.Flags, 255, 255); err != nil { + return err + } + + return nil +} + +func (l *Lane) String() string { + return fmt.Sprintf("<%s cap=%d lat=%d>", l.ID, l.Capacity, l.Latency) +} + +// LanesEqual returns whether the given []*Lane are equal. +func LanesEqual(a, b []*Lane) bool { + if len(a) != len(b) { + return false + } + + for i, l := range a { + if !l.Equal(b[i]) { + return false + } + } + + return true +} + +type lanes []*Lane + +func (l lanes) Len() int { return len(l) } +func (l lanes) Swap(i, j int) { l[i], l[j] = l[j], l[i] } +func (l lanes) Less(i, j int) bool { return l[i].ID < l[j].ID } + +// SortLanes sorts a slice of Lanes. +func SortLanes(l []*Lane) { + sort.Sort(lanes(l)) +} + +// HasFlag returns whether the Status has the given flag set. +func (s *Status) HasFlag(flagName string) bool { + return slices.Contains[[]string, string](s.Flags, flagName) +} + +// FlagsEqual returns whether the given status flags are equal. +func FlagsEqual(a, b []string) bool { + // Cannot be equal if lengths are different. + if len(a) != len(b) { + return false + } + + // If both are empty, they are equal. + if len(a) == 0 { + return true + } + + // Make sure flags are sorted before comparing values. + sort.Strings(a) + sort.Strings(b) + + // Compare values. + for i, v := range a { + if v != b[i] { + return false + } + } + + return true +} diff --git a/spn/hub/transport.go b/spn/hub/transport.go new file mode 100644 index 00000000..aa8f3bf9 --- /dev/null +++ b/spn/hub/transport.go @@ -0,0 +1,152 @@ +package hub + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "golang.org/x/exp/slices" +) + +// Examples: +// "spn:17", +// "smtp:25", +// "smtp:587", +// "imap:143", +// "http:80", +// "http://example.com:80/example", // HTTP (based): use full path for request +// "https:443", +// "ws:80", +// "wss://example.com:443/spn", + +// Transport represents a "endpoint" that others can connect to. This allows for use of different protocols, ports and infrastructure integration. +type Transport struct { + Protocol string + Domain string + Port uint16 + Path string + Option string +} + +// ParseTransports returns a list of parsed transports and errors from parsing +// the given definitions. +func ParseTransports(definitions []string) (transports []*Transport, errs []error) { + transports = make([]*Transport, 0, len(definitions)) + for _, definition := range definitions { + parsed, err := ParseTransport(definition) + if err != nil { + errs = append(errs, fmt.Errorf( + "unknown or invalid transport %q: %w", definition, err, + )) + } else { + transports = append(transports, parsed) + } + } + + SortTransports(transports) + return transports, errs +} + +// ParseTransport parses a transport definition. +func ParseTransport(definition string) (*Transport, error) { + u, err := url.Parse(definition) + if err != nil { + return nil, err + } + + // check for invalid parts + if u.User != nil { + return nil, errors.New("user/pass is not allowed") + } + + // put into transport + t := &Transport{ + Protocol: u.Scheme, + Domain: u.Hostname(), + Path: u.RequestURI(), + Option: u.Fragment, + } + + // parse port + portData := u.Port() + if portData == "" { + // no port available - it might be in u.Opaque, which holds both the port and possibly a path + portData = strings.SplitN(u.Opaque, "/", 2)[0] // get port + t.Path = strings.TrimPrefix(t.Path, portData) // trim port from path + // check again for port + if portData == "" { + return nil, errors.New("missing port") + } + } + port, err := strconv.ParseUint(portData, 10, 16) + if err != nil { + return nil, errors.New("invalid port") + } + t.Port = uint16(port) + + // check port + if t.Port == 0 { + return nil, errors.New("invalid port") + } + + // remove root paths + if t.Path == "/" { + t.Path = "" + } + + // check for protocol + if t.Protocol == "" { + return nil, errors.New("missing scheme/protocol") + } + + return t, nil +} + +// String returns the definition form of the transport. +func (t *Transport) String() string { + switch { + case t.Option != "": + return fmt.Sprintf("%s://%s:%d%s#%s", t.Protocol, t.Domain, t.Port, t.Path, t.Option) + case t.Domain != "": + return fmt.Sprintf("%s://%s:%d%s", t.Protocol, t.Domain, t.Port, t.Path) + default: + return fmt.Sprintf("%s:%d%s", t.Protocol, t.Port, t.Path) + } +} + +// SortTransports sorts the transports to emphasize certain protocols, but +// otherwise leaves the order intact. +func SortTransports(ts []*Transport) { + slices.SortStableFunc[[]*Transport, *Transport](ts, func(a, b *Transport) int { + aOrder := a.protocolOrder() + bOrder := b.protocolOrder() + + switch { + case aOrder != bOrder: + return aOrder - bOrder + // case a.Port != b.Port: + // return int(a.Port) - int(b.Port) + // case a.Domain != b.Domain: + // return strings.Compare(a.Domain, b.Domain) + // case a.Path != b.Path: + // return strings.Compare(a.Path, b.Path) + // case a.Option != b.Option: + // return strings.Compare(a.Option, b.Option) + default: + return 0 + } + }) +} + +func (t *Transport) protocolOrder() int { + switch t.Protocol { + case "http": + return 1 + case "spn": + return 2 + default: + return 100 + } +} diff --git a/spn/hub/transport_test.go b/spn/hub/transport_test.go new file mode 100644 index 00000000..9ed31b6f --- /dev/null +++ b/spn/hub/transport_test.go @@ -0,0 +1,148 @@ +package hub + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func parseT(t *testing.T, definition string) *Transport { + t.Helper() + + tr, err := ParseTransport(definition) + if err != nil { + t.Fatal(err) + return nil + } + return tr +} + +func parseTError(definition string) error { + _, err := ParseTransport(definition) + return err +} + +func TestTransportParsing(t *testing.T) { + t.Parallel() + + // test parsing + + assert.Equal(t, &Transport{ + Protocol: "spn", + Port: 17, + }, parseT(t, "spn:17"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "smtp", + Port: 25, + }, parseT(t, "smtp:25"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "smtp", + Port: 25, + }, parseT(t, "smtp://:25"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "smtp", + Port: 587, + }, parseT(t, "smtp:587"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "imap", + Port: 143, + }, parseT(t, "imap:143"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "http", + Port: 80, + }, parseT(t, "http:80"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "http", + Domain: "example.com", + Port: 80, + }, parseT(t, "http://example.com:80"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "https", + Port: 443, + }, parseT(t, "https:443"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "ws", + Port: 80, + }, parseT(t, "ws:80"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "wss", + Domain: "example.com", + Port: 443, + Path: "/spn", + }, parseT(t, "wss://example.com:443/spn"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "http", + Domain: "example.com", + Port: 80, + }, parseT(t, "http://example.com:80"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "http", + Domain: "example.com", + Port: 80, + Path: "/test%20test", + }, parseT(t, "http://example.com:80/test test"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "http", + Domain: "example.com", + Port: 80, + Path: "/test%20test", + }, parseT(t, "http://example.com:80/test%20test"), "should match") + + assert.Equal(t, &Transport{ + Protocol: "http", + Domain: "example.com", + Port: 80, + Path: "/test?key=value", + }, parseT(t, "http://example.com:80/test?key=value"), "should match") + + // test parsing and formatting + + assert.Equal(t, "spn:17", + parseT(t, "spn:17").String(), "should match") + assert.Equal(t, "smtp:25", + parseT(t, "smtp:25").String(), "should match") + assert.Equal(t, "smtp:25", + parseT(t, "smtp://:25").String(), "should match") + assert.Equal(t, "smtp:587", + parseT(t, "smtp:587").String(), "should match") + assert.Equal(t, "imap:143", + parseT(t, "imap:143").String(), "should match") + assert.Equal(t, "http:80", + parseT(t, "http:80").String(), "should match") + assert.Equal(t, "http://example.com:80", + parseT(t, "http://example.com:80").String(), "should match") + assert.Equal(t, "https:443", + parseT(t, "https:443").String(), "should match") + assert.Equal(t, "ws:80", + parseT(t, "ws:80").String(), "should match") + assert.Equal(t, "wss://example.com:443/spn", + parseT(t, "wss://example.com:443/spn").String(), "should match") + assert.Equal(t, "http://example.com:80", + parseT(t, "http://example.com:80").String(), "should match") + assert.Equal(t, "http://example.com:80/test%20test", + parseT(t, "http://example.com:80/test test").String(), "should match") + assert.Equal(t, "http://example.com:80/test%20test", + parseT(t, "http://example.com:80/test%20test").String(), "should match") + assert.Equal(t, "http://example.com:80/test?key=value", + parseT(t, "http://example.com:80/test?key=value").String(), "should match") + + // test invalid + + require.Error(t, parseTError("spn"), "should fail") + require.Error(t, parseTError("spn:"), "should fail") + require.Error(t, parseTError("spn:0"), "should fail") + require.Error(t, parseTError("spn:65536"), "should fail") +} diff --git a/spn/hub/truststores.go b/spn/hub/truststores.go new file mode 100644 index 00000000..8f06a55d --- /dev/null +++ b/spn/hub/truststores.go @@ -0,0 +1,17 @@ +package hub + +import "github.com/safing/jess" + +// SingleTrustStore is a simple truststore that always returns the same Signet. +type SingleTrustStore struct { + Signet *jess.Signet +} + +// GetSignet implements the truststore interface. +func (ts *SingleTrustStore) GetSignet(id string, recipient bool) (*jess.Signet, error) { + if ts.Signet.ID != id || recipient != ts.Signet.Public { + return nil, jess.ErrSignetNotFound + } + + return ts.Signet, nil +} diff --git a/spn/hub/update.go b/spn/hub/update.go new file mode 100644 index 00000000..4e280b1a --- /dev/null +++ b/spn/hub/update.go @@ -0,0 +1,524 @@ +package hub + +import ( + "errors" + "fmt" + "time" + + "github.com/safing/jess" + "github.com/safing/jess/lhash" + "github.com/safing/portbase/container" + "github.com/safing/portbase/database" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/network/netutils" +) + +var ( + // hubMsgRequirements defines which security attributes message need to have. + hubMsgRequirements = jess.NewRequirements(). + Remove(jess.RecipientAuthentication). // Recipient don't need a private key. + Remove(jess.Confidentiality). // Message contents are out in the open. + Remove(jess.Integrity) // Only applies to decryption. + // SenderAuthentication provides pre-decryption integrity. That is all we need. + + clockSkewTolerance = 12 * time.Hour +) + +// SignHubMsg signs the given serialized hub msg with the given configuration. +func SignHubMsg(msg []byte, env *jess.Envelope, enableTofu bool) ([]byte, error) { + // start session from envelope + session, err := env.Correspondence(nil) + if err != nil { + return nil, fmt.Errorf("failed to initiate signing session: %w", err) + } + // sign the data + letter, err := session.Close(msg) + if err != nil { + return nil, fmt.Errorf("failed to sign msg: %w", err) + } + + if enableTofu { + // smuggle the public key + // letter.Keys is usually only used for key exchanges and encapsulation + // neither is used when signing, so we can use letter.Keys to transport public keys + for _, sender := range env.Senders { + // get public key + public, err := sender.AsRecipient() + if err != nil { + return nil, fmt.Errorf("failed to get public key of %s: %w", sender.ID, err) + } + // serialize key + err = public.StoreKey() + if err != nil { + return nil, fmt.Errorf("failed to serialize public key %s: %w", sender.ID, err) + } + // add to keys + letter.Keys = append(letter.Keys, &jess.Seal{ + Value: public.Key, + }) + } + } + + // pack + data, err := letter.ToDSD(dsd.JSON) + if err != nil { + return nil, err + } + + return data, nil +} + +// OpenHubMsg opens a signed hub msg and verifies the signature using the +// provided hub or the local database. If TOFU is enabled, the signature is +// always accepted, if valid. +func OpenHubMsg(hub *Hub, data []byte, mapName string, tofu bool) (msg []byte, sendingHub *Hub, known bool, err error) { + letter, err := jess.LetterFromDSD(data) + if err != nil { + return nil, nil, false, fmt.Errorf("malformed letter: %w", err) + } + + // check signatures + var seal *jess.Seal + switch len(letter.Signatures) { + case 0: + return nil, nil, false, errors.New("missing signature") + case 1: + seal = letter.Signatures[0] + default: + return nil, nil, false, fmt.Errorf("too many signatures (%d)", len(letter.Signatures)) + } + + // check signature signer ID + if seal.ID == "" { + return nil, nil, false, errors.New("signature is missing signer ID") + } + + // get hub for public key + if hub == nil { + hub, err = GetHub(mapName, seal.ID) + if err != nil { + if !errors.Is(err, database.ErrNotFound) { + return nil, nil, false, fmt.Errorf("failed to get existing hub %s: %w", seal.ID, err) + } + hub = nil + } else { + known = true + } + } else { + known = true + } + + var truststore jess.TrustStore + if hub != nil && hub.PublicKey != nil { // bootstrap entries will not have a public key + // check ID integrity + if hub.ID != seal.ID { + return nil, hub, known, fmt.Errorf("ID mismatch with hub msg ID %s and hub ID %s", seal.ID, hub.ID) + } + if !verifyHubID(seal.ID, hub.PublicKey.Scheme, hub.PublicKey.Key) { + return nil, hub, known, fmt.Errorf("ID integrity of %s violated with existing key", seal.ID) + } + } else { + if !tofu { + return nil, nil, false, fmt.Errorf("hub msg ID %s unknown (missing announcement)", seal.ID) + } + + // trust on first use, extract key from keys + // TODO: Test if works without TOFU. + + // get key + var pubkey *jess.Seal + switch len(letter.Keys) { + case 0: + return nil, nil, false, fmt.Errorf("missing key for TOFU of %s", seal.ID) + case 1: + pubkey = letter.Keys[0] + default: + return nil, nil, false, fmt.Errorf("too many keys (%d) for TOFU of %s", len(letter.Keys), seal.ID) + } + + // check ID integrity + if !verifyHubID(seal.ID, seal.Scheme, pubkey.Value) { + return nil, nil, false, fmt.Errorf("ID integrity of %s violated with new key", seal.ID) + } + + hub = &Hub{ + ID: seal.ID, + Map: mapName, + PublicKey: &jess.Signet{ + ID: seal.ID, + Scheme: seal.Scheme, + Key: pubkey.Value, + Public: true, + }, + } + err = hub.PublicKey.LoadKey() + if err != nil { + return nil, nil, false, err + } + } + + // create trust store + truststore = &SingleTrustStore{hub.PublicKey} + + // remove keys from letter, as they are only used to transfer the public key + letter.Keys = nil + + // check signature + err = letter.Verify(hubMsgRequirements, truststore) + if err != nil { + return nil, nil, false, err + } + + return letter.Data, hub, known, nil +} + +// Export exports the announcement with the given signature configuration. +func (a *Announcement) Export(env *jess.Envelope) ([]byte, error) { + // pack + msg, err := dsd.Dump(a, dsd.JSON) + if err != nil { + return nil, fmt.Errorf("failed to pack announcement: %w", err) + } + + return SignHubMsg(msg, env, true) +} + +// ApplyAnnouncement applies the announcement to the Hub if it passes all the +// checks. If no Hub is provided, it is loaded from the database or created. +func ApplyAnnouncement(existingHub *Hub, data []byte, mapName string, scope Scope, selfcheck bool) (hub *Hub, known, changed bool, err error) { + // Set valid/invalid status based on the return error. + var announcement *Announcement + defer func() { + if hub != nil { + if err != nil && !errors.Is(err, ErrOldData) { + hub.InvalidInfo = true + } else { + hub.InvalidInfo = false + } + } + }() + + // open and verify + var msg []byte + msg, hub, known, err = OpenHubMsg(existingHub, data, mapName, true) + + // Lock hub if we have one. + if hub != nil && !selfcheck { + hub.Lock() + defer hub.Unlock() + } + + // Check if there was an error with the Hub msg. + if err != nil { + return //nolint:nakedret + } + + // parse + announcement = &Announcement{} + _, err = dsd.Load(msg, announcement) + if err != nil { + return //nolint:nakedret + } + + // integrity check + + // `hub.ID` is taken from the first ever received announcement message. + // `announcement.ID` is additionally present in the message as we need + // a signed version of the ID to mitigate fake IDs. + // Fake IDs are possible because the hash algorithm of the ID is dynamic. + if hub.ID != announcement.ID { + err = fmt.Errorf("announcement ID %q mismatches hub ID %q", announcement.ID, hub.ID) + return //nolint:nakedret + } + + // version check + if hub.Info != nil { + // check if we already have this version + switch { + case announcement.Timestamp == hub.Info.Timestamp && !selfcheck: + // The new copy is not saved, as we expect the versions to be identical. + // Also, the new version has not been validated at this point. + return //nolint:nakedret + case announcement.Timestamp < hub.Info.Timestamp: + // Received an old version, do not update. + err = fmt.Errorf( + "%wannouncement from %s @ %s is older than current status @ %s", + ErrOldData, hub.StringWithoutLocking(), time.Unix(announcement.Timestamp, 0), time.Unix(hub.Info.Timestamp, 0), + ) + return //nolint:nakedret + } + } + + // We received a new version. + changed = true + + // Update timestamp here already in case validation fails. + if hub.Info != nil { + hub.Info.Timestamp = announcement.Timestamp + } + + // Validate the announcement. + err = hub.validateAnnouncement(announcement, scope) + if err != nil { + if selfcheck || hub.FirstSeen.IsZero() { + err = fmt.Errorf("failed to validate announcement of %s: %w", hub.StringWithoutLocking(), err) + return //nolint:nakedret + } + + log.Warningf("spn/hub: received an invalid announcement of %s: %s", hub.StringWithoutLocking(), err) + // If a previously fully validated Hub publishes an update that breaks it, a + // soft-fail will accept the faulty changes, but mark is as invalid and + // forward it to neighbors. This way the invalid update is propagated through + // the network and all nodes will mark it as invalid an thus ingore the Hub + // until the issue is fixed. + } + + // Only save announcement if it is valid. + if err == nil { + hub.Info = announcement + } + // Set FirstSeen timestamp when we see this Hub for the first time. + if hub.FirstSeen.IsZero() { + hub.FirstSeen = time.Now().UTC() + } + + return //nolint:nakedret +} + +func (h *Hub) validateAnnouncement(announcement *Announcement, scope Scope) error { + // value formatting + if err := announcement.validateFormatting(); err != nil { + return err + } + // check parsables + if err := announcement.prepare(true); err != nil { + return fmt.Errorf("failed to prepare announcement: %w", err) + } + + // check timestamp + if announcement.Timestamp > time.Now().Add(clockSkewTolerance).Unix() { + return fmt.Errorf( + "announcement from %s @ %s is from the future", + announcement.ID, + time.Unix(announcement.Timestamp, 0), + ) + } + + // check for illegal IP address changes + if h.Info != nil { + switch { + case h.Info.IPv4 != nil && announcement.IPv4 == nil: + h.VerifiedIPs = false + return errors.New("previously announced IPv4 address missing") + case h.Info.IPv4 != nil && !announcement.IPv4.Equal(h.Info.IPv4): + h.VerifiedIPs = false + return errors.New("IPv4 address changed") + case h.Info.IPv6 != nil && announcement.IPv6 == nil: + h.VerifiedIPs = false + return errors.New("previously announced IPv6 address missing") + case h.Info.IPv6 != nil && !announcement.IPv6.Equal(h.Info.IPv6): + h.VerifiedIPs = false + return errors.New("IPv6 address changed") + } + } + + // validate IP scopes + if announcement.IPv4 != nil { + ipScope := netutils.GetIPScope(announcement.IPv4) + switch { + case scope == ScopeLocal && !ipScope.IsLAN(): + return errors.New("IPv4 scope violation: outside of local scope") + case scope == ScopePublic && !ipScope.IsGlobal(): + return errors.New("IPv4 scope violation: outside of global scope") + } + // Reset IP verification flag if IPv4 was added. + if h.Info == nil || h.Info.IPv4 == nil { + h.VerifiedIPs = false + } + } + if announcement.IPv6 != nil { + ipScope := netutils.GetIPScope(announcement.IPv6) + switch { + case scope == ScopeLocal && !ipScope.IsLAN(): + return errors.New("IPv6 scope violation: outside of local scope") + case scope == ScopePublic && !ipScope.IsGlobal(): + return errors.New("IPv6 scope violation: outside of global scope") + } + // Reset IP verification flag if IPv6 was added. + if h.Info == nil || h.Info.IPv6 == nil { + h.VerifiedIPs = false + } + } + + return nil +} + +// Export exports the status with the given signature configuration. +func (s *Status) Export(env *jess.Envelope) ([]byte, error) { + // pack + msg, err := dsd.Dump(s, dsd.JSON) + if err != nil { + return nil, fmt.Errorf("failed to pack status: %w", err) + } + + return SignHubMsg(msg, env, false) +} + +// ApplyStatus applies a status update if it passes all the checks. +func ApplyStatus(existingHub *Hub, data []byte, mapName string, scope Scope, selfcheck bool) (hub *Hub, known, changed bool, err error) { + // Set valid/invalid status based on the return error. + defer func() { + if hub != nil { + if err != nil && !errors.Is(err, ErrOldData) { + hub.InvalidStatus = true + } else { + hub.InvalidStatus = false + } + } + }() + + // open and verify + var msg []byte + msg, hub, known, err = OpenHubMsg(existingHub, data, mapName, false) + + // Lock hub if we have one. + if hub != nil && !selfcheck { + hub.Lock() + defer hub.Unlock() + } + + // Check if there was an error with the Hub msg. + if err != nil { + return //nolint:nakedret + } + + // parse + status := &Status{} + _, err = dsd.Load(msg, status) + if err != nil { + return //nolint:nakedret + } + + // version check + if hub.Status != nil { + // check if we already have this version + switch { + case status.Timestamp == hub.Status.Timestamp && !selfcheck: + // The new copy is not saved, as we expect the versions to be identical. + // Also, the new version has not been validated at this point. + return //nolint:nakedret + case status.Timestamp < hub.Status.Timestamp: + // Received an old version, do not update. + err = fmt.Errorf( + "%wstatus from %s @ %s is older than current status @ %s", + ErrOldData, hub.StringWithoutLocking(), time.Unix(status.Timestamp, 0), time.Unix(hub.Status.Timestamp, 0), + ) + return //nolint:nakedret + } + } + + // We received a new version. + changed = true + + // Update timestamp here already in case validation fails. + if hub.Status != nil { + hub.Status.Timestamp = status.Timestamp + } + + // Validate the status. + err = hub.validateStatus(status) + if err != nil { + if selfcheck { + err = fmt.Errorf("failed to validate status of %s: %w", hub.StringWithoutLocking(), err) + return //nolint:nakedret + } + + log.Warningf("spn/hub: received an invalid status of %s: %s", hub.StringWithoutLocking(), err) + // If a previously fully validated Hub publishes an update that breaks it, a + // soft-fail will accept the faulty changes, but mark is as invalid and + // forward it to neighbors. This way the invalid update is propagated through + // the network and all nodes will mark it as invalid an thus ingore the Hub + // until the issue is fixed. + } + + // Only save status if it is valid, else mark it as invalid. + if err == nil { + hub.Status = status + } + + return //nolint:nakedret +} + +func (h *Hub) validateStatus(status *Status) error { + // value formatting + if err := status.validateFormatting(); err != nil { + return err + } + + // check timestamp + if status.Timestamp > time.Now().Add(clockSkewTolerance).Unix() { + return fmt.Errorf( + "status from %s @ %s is from the future", + h.ID, + time.Unix(status.Timestamp, 0), + ) + } + + // TODO: validate status.Keys + + return nil +} + +// CreateHubSignet creates a signet with the correct ID for usage as a Hub Identity. +func CreateHubSignet(toolID string, securityLevel int) (private, public *jess.Signet, err error) { + private, err = jess.GenerateSignet(toolID, securityLevel) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate key: %w", err) + } + err = private.StoreKey() + if err != nil { + return nil, nil, fmt.Errorf("failed to store private key: %w", err) + } + + // get public key for creating the Hub ID + public, err = private.AsRecipient() + if err != nil { + return nil, nil, fmt.Errorf("failed to get public key: %w", err) + } + err = public.StoreKey() + if err != nil { + return nil, nil, fmt.Errorf("failed to store public key: %w", err) + } + + // assign IDs + private.ID = createHubID(public.Scheme, public.Key) + public.ID = private.ID + + return private, public, nil +} + +func createHubID(scheme string, pubkey []byte) string { + // compile scheme and public key + c := container.New() + c.AppendAsBlock([]byte(scheme)) + c.AppendAsBlock(pubkey) + + return lhash.Digest(lhash.BLAKE2b_256, c.CompileData()).Base58() +} + +func verifyHubID(id string, scheme string, pubkey []byte) (ok bool) { + // load labeled hash from ID + labeledHash, err := lhash.FromBase58(id) + if err != nil { + return false + } + + // compile scheme and public key + c := container.New() + c.AppendAsBlock([]byte(scheme)) + c.AppendAsBlock(pubkey) + + // check if it matches + return labeledHash.MatchesData(c.CompileData()) +} diff --git a/spn/hub/update_test.go b/spn/hub/update_test.go new file mode 100644 index 00000000..982f3206 --- /dev/null +++ b/spn/hub/update_test.go @@ -0,0 +1,70 @@ +package hub + +import ( + "fmt" + "testing" + + "github.com/safing/jess" + "github.com/safing/portbase/formats/dsd" +) + +func TestHubUpdate(t *testing.T) { + t.Parallel() + + // message signing + + testData := []byte{0} + + s1, err := jess.GenerateSignet("Ed25519", 0) + if err != nil { + t.Fatal(err) + } + err = s1.StoreKey() + if err != nil { + t.Fatal(err) + } + fmt.Printf("s1: %+v\n", s1) + + s1e, err := s1.AsRecipient() + if err != nil { + t.Fatal(err) + } + err = s1e.StoreKey() + if err != nil { + t.Fatal(err) + } + s1e.ID = createHubID(s1e.Scheme, s1e.Key) + s1.ID = s1e.ID + + t.Logf("generated hub ID: %s", s1.ID) + + env := jess.NewUnconfiguredEnvelope() + env.SuiteID = jess.SuiteSignV1 + env.Senders = []*jess.Signet{s1} + + s, err := env.Correspondence(nil) + if err != nil { + t.Fatal(err) + } + letter, err := s.Close(testData) + if err != nil { + t.Fatal(err) + } + + // smuggle the key + letter.Keys = append(letter.Keys, &jess.Seal{ + Value: s1e.Key, + }) + t.Logf("letter with smuggled key: %+v", letter) + + // pack + data, err := letter.ToDSD(dsd.JSON) + if err != nil { + t.Fatal(err) + } + + _, _, _, err = OpenHubMsg(nil, data, "test", true) //nolint:dogsled + if err != nil { + t.Fatal(err) + } +} diff --git a/spn/navigator/api.go b/spn/navigator/api.go new file mode 100644 index 00000000..832d1126 --- /dev/null +++ b/spn/navigator/api.go @@ -0,0 +1,672 @@ +package navigator + +import ( + "bytes" + "errors" + "fmt" + "math" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "text/tabwriter" + "time" + + "github.com/awalterschulze/gographviz" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" +) + +var ( + apiMapsLock sync.Mutex + apiMaps = make(map[string]*Map) +) + +func addMapToAPI(m *Map) { + apiMapsLock.Lock() + defer apiMapsLock.Unlock() + + apiMaps[m.Name] = m +} + +func getMapForAPI(name string) (m *Map, ok bool) { + apiMapsLock.Lock() + defer apiMapsLock.Unlock() + + m, ok = apiMaps[name] + return +} + +func removeMapFromAPI(name string) { + apiMapsLock.Lock() + defer apiMapsLock.Unlock() + + delete(apiMaps, name) +} + +func registerAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/pins`, + Read: api.PermitUser, + BelongsTo: module, + StructFunc: handleMapPinsRequest, + Name: "Get SPN map pins", + Description: "Returns a list of pins on the map.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/intel/update`, + Write: api.PermitSelf, + BelongsTo: module, + ActionFunc: handleIntelUpdateRequest, + Name: "Update map intelligence.", + Description: "Updates the intel data of the map.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/optimization`, + Read: api.PermitUser, + BelongsTo: module, + StructFunc: handleMapOptimizationRequest, + Name: "Get SPN map optimization", + Description: "Returns the calculated optimization for the map.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/optimization/table`, + Read: api.PermitUser, + BelongsTo: module, + DataFunc: handleMapOptimizationTableRequest, + Name: "Get SPN map optimization as a table", + Description: "Returns the calculated optimization for the map as a table.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/measurements`, + Read: api.PermitUser, + BelongsTo: module, + StructFunc: handleMapMeasurementsRequest, + Name: "Get SPN map measurements", + Description: "Returns the measurements of the map.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/measurements/table`, + MimeType: api.MimeTypeText, + Read: api.PermitUser, + BelongsTo: module, + DataFunc: handleMapMeasurementsTableRequest, + Name: "Get SPN map measurements as a table", + Description: "Returns the measurements of the map as a table.", + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/graph{format:\.[a-z]{2,4}}`, + Read: api.PermitUser, + BelongsTo: module, + HandlerFunc: handleMapGraphRequest, + Name: "Get SPN map graph", + Description: "Returns a graph of the given SPN map.", + Parameters: []api.Parameter{ + { + Method: http.MethodGet, + Field: "map (in path)", + Value: "name of map", + Description: "Specify the map you want to get the map for. The main map is called `main`.", + }, + { + Method: http.MethodGet, + Field: "format (in path)", + Value: "file type", + Description: "Specify the format you want to get the map in. Available values: `dot`, `html`. Please note that the html format is only available in development mode.", + }, + }, + }); err != nil { + return err + } + + // Register API endpoints from other files. + if err := registerRouteAPIEndpoints(); err != nil { + return err + } + + return nil +} + +func handleMapPinsRequest(ar *api.Request) (i interface{}, err error) { + // Get map. + m, ok := getMapForAPI(ar.URLVars["map"]) + if !ok { + return nil, errors.New("map not found") + } + + // Export all pins. + sortedPins := m.sortedPins(true) + exportedPins := make([]*PinExport, len(sortedPins)) + for key, pin := range sortedPins { + exportedPins[key] = pin.Export() + } + + return exportedPins, nil +} + +func handleIntelUpdateRequest(ar *api.Request) (msg string, err error) { + // Get map. + m, ok := getMapForAPI(ar.URLVars["map"]) + if !ok { + return "", errors.New("map not found") + } + + // Parse new intel data. + newIntel, err := hub.ParseIntel(ar.InputData) + if err != nil { + return "", fmt.Errorf("failed to parse intel data: %w", err) + } + + // Apply intel data. + err = m.UpdateIntel(newIntel, cfgOptionTrustNodeNodes()) + if err != nil { + return "", fmt.Errorf("failed to apply intel data: %w", err) + } + + return "successfully applied given intel data", nil +} + +func handleMapOptimizationRequest(ar *api.Request) (i interface{}, err error) { + // Get map. + m, ok := getMapForAPI(ar.URLVars["map"]) + if !ok { + return nil, errors.New("map not found") + } + + return m.Optimize(nil) +} + +func handleMapOptimizationTableRequest(ar *api.Request) (data []byte, err error) { + // Get map. + m, ok := getMapForAPI(ar.URLVars["map"]) + if !ok { + return nil, errors.New("map not found") + } + + // Get optimization result. + result, err := m.Optimize(nil) + if err != nil { + return nil, err + } + + // Read lock map, as we access pins. + m.RLock() + defer m.RUnlock() + + // Get cranes for additional metadata. + assignedCranes := docks.GetAllAssignedCranes() + + // Write metadata. + buf := bytes.NewBuffer(nil) + buf.WriteString("Optimization:\n") + fmt.Fprintf(buf, "Purpose: %s\n", result.Purpose) + if len(result.Approach) == 1 { + fmt.Fprintf(buf, "Approach: %s\n", result.Approach[0]) + } else if len(result.Approach) > 1 { + buf.WriteString("Approach:\n") + for _, approach := range result.Approach { + fmt.Fprintf(buf, " - %s\n", approach) + } + } + fmt.Fprintf(buf, "MaxConnect: %d\n", result.MaxConnect) + fmt.Fprintf(buf, "StopOthers: %v\n", result.StopOthers) + + // Build table of suggested connections. + buf.WriteString("\nSuggested Connections:\n") + tabWriter := tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0) + fmt.Fprint(tabWriter, "Hub Name\tReason\tDuplicate\tCountry\tRegion\tLatency\tCapacity\tCost\tGeo Prox.\tHub ID\tLifetime Usage\tPeriod Usage\tProt\tStatus\n") + for _, suggested := range result.SuggestedConnections { + var dupe string + if suggested.Duplicate { + dupe = "yes" + } else { + // Only lock dupes once. + suggested.pin.measurements.Lock() + defer suggested.pin.measurements.Unlock() + } + + // Add row. + fmt.Fprintf(tabWriter, + "%s\t%s\t%s\t%s\t%s\t%s\t%.2fMbit/s\t%.2fc\t%.2f%%\t%s", + suggested.Hub.Info.Name, + suggested.Reason, + dupe, + getPinCountry(suggested.pin), + suggested.pin.region.getName(), + suggested.pin.measurements.Latency, + float64(suggested.pin.measurements.Capacity)/1000000, + suggested.pin.measurements.CalculatedCost, + suggested.pin.measurements.GeoProximity, + suggested.Hub.ID, + ) + + // Add usage stats. + if crane, ok := assignedCranes[suggested.Hub.ID]; ok { + addUsageStatsToTable(crane, tabWriter) + } + + // Add linebreak. + fmt.Fprint(tabWriter, "\n") + } + _ = tabWriter.Flush() + + return buf.Bytes(), nil +} + +// addUsageStatsToTable compiles some usage stats of a lane and addes them to the table. +// Table Fields: Lifetime Usage, Period Usage, Prot, Mine. +func addUsageStatsToTable(crane *docks.Crane, tabWriter *tabwriter.Writer) { + ltIn, ltOut, ltStart, pIn, pOut, pStart := crane.NetState.GetTrafficStats() + ltDuration := time.Since(ltStart) + pDuration := time.Since(pStart) + + // Build ownership and stopping info. + var status string + isMine := crane.IsMine() + isStopping := crane.IsStopping() + stoppingRequested, stoppingRequestedByPeer, markedStoppingAt := crane.NetState.StoppingState() + if isMine { + status = "mine" + } + if isStopping || stoppingRequested || stoppingRequestedByPeer { + if isMine { + status += " - " + } + status += "stopping " + if stoppingRequested { + status += " + +
+ + + + +`, + "`", graph.String(), "`", + )) + } + + // Write response. + w.Header().Set("Content-Type", mimeType+"; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(len(responseData))) + w.WriteHeader(http.StatusOK) + _, err := w.Write(responseData) + if err != nil { + log.Tracer(r.Context()).Warningf("api: failed to write response: %s", err) + } +} + +func graphNodeLabel(pin *Pin) (s string) { + var comment string + switch { + case pin.State == StateNone: + comment = "dead" + case pin.State.Has(StateIsHomeHub): + comment = "Home" + case pin.State.HasAnyOf(StateSummaryDisregard): + comment = "disregarded" + case !pin.State.Has(StateSummaryRegard): + comment = "not regarded" + case pin.State.Has(StateTrusted): + comment = "trusted" + } + if comment != "" { + comment = fmt.Sprintf("\n(%s)", comment) + } + + if pin.Hub.Status.Load >= 80 { + comment += fmt.Sprintf("\nHIGH LOAD: %d", pin.Hub.Status.Load) + } + + return fmt.Sprintf( + `"%s%s"`, + strings.ReplaceAll(pin.Hub.Name(), " ", "\n"), + comment, + ) +} + +func graphNodeTooltip(pin *Pin) string { + // Gather IP info. + var v4Info, v6Info string + if pin.Hub.Info.IPv4 != nil { + if pin.LocationV4 != nil { + v4Info = fmt.Sprintf( + "%s (%s AS%d %s)", + pin.Hub.Info.IPv4.String(), + pin.LocationV4.Country.Code, + pin.LocationV4.AutonomousSystemNumber, + pin.LocationV4.AutonomousSystemOrganization, + ) + } else { + v4Info = pin.Hub.Info.IPv4.String() + } + } + if pin.Hub.Info.IPv6 != nil { + if pin.LocationV6 != nil { + v6Info = fmt.Sprintf( + "%s (%s AS%d %s)", + pin.Hub.Info.IPv6.String(), + pin.LocationV6.Country.Code, + pin.LocationV6.AutonomousSystemNumber, + pin.LocationV6.AutonomousSystemOrganization, + ) + } else { + v6Info = pin.Hub.Info.IPv6.String() + } + } + + return fmt.Sprintf( + `"ID: %s +States: %s +Version: %s +IPv4: %s +IPv6: %s +Load: %d +Cost: %.2f"`, + pin.Hub.ID, + pin.State, + pin.Hub.Status.Version, + v4Info, + v6Info, + pin.Hub.Status.Load, + pin.Cost, + ) +} + +func graphEdgeTooltip(from, to *Pin, lane *Lane) string { + return fmt.Sprintf( + `"%s <> %s +Latency: %s +Capacity: %.2f Mbit/s +Cost: %.2f"`, + from.Hub.Info.Name, to.Hub.Info.Name, + lane.Latency, + float64(lane.Capacity)/1000000, + lane.Cost, + ) +} + +// Graphviz colors. +// See https://graphviz.org/doc/info/colors.html +const ( + graphColorWarning = "orange2" + graphColorError = "red2" + graphColorHomeAndConnected = "steelblue2" + graphColorDisregard = "tomato2" + graphColorNotRegard = "tan2" + graphColorTrusted = "seagreen2" + graphColorDefaultNode = "seashell2" + graphColorDefaultEdge = "black" + graphColorNone = "transparent" +) + +func graphNodeColor(pin *Pin) string { + switch { + case pin.State == StateNone: + return graphColorNone + case pin.Hub.Status.Load >= 95: + return graphColorError + case pin.Hub.Status.Load >= 80: + return graphColorWarning + case pin.State.Has(StateIsHomeHub): + return graphColorHomeAndConnected + case pin.State.HasAnyOf(StateSummaryDisregard): + return graphColorDisregard + case !pin.State.Has(StateSummaryRegard): + return graphColorNotRegard + case pin.State.Has(StateTrusted): + return graphColorTrusted + default: + return graphColorDefaultNode + } +} + +func graphNodeBorderColor(pin *Pin) string { + switch { + case pin.HasActiveTerminal(): + return graphColorHomeAndConnected + default: + return graphColorNone + } +} + +func graphEdgeColor(from, to *Pin, lane *Lane) string { + // Check lane stats. + if lane.Capacity == 0 || lane.Latency == 0 { + return graphColorWarning + } + // Alert if capacity is under 10Mbit/s or latency is over 100ms. + if lane.Capacity < 10000000 || lane.Latency > 100*time.Millisecond { + return graphColorError + } + + // Check for active edge forward. + if to.HasActiveTerminal() && len(to.Connection.Route.Path) >= 2 { + secondLastHopIndex := len(to.Connection.Route.Path) - 2 + if to.Connection.Route.Path[secondLastHopIndex].HubID == from.Hub.ID { + return graphColorHomeAndConnected + } + } + // Check for active edge backward. + if from.HasActiveTerminal() && len(from.Connection.Route.Path) >= 2 { + secondLastHopIndex := len(from.Connection.Route.Path) - 2 + if from.Connection.Route.Path[secondLastHopIndex].HubID == to.Hub.ID { + return graphColorHomeAndConnected + } + } + + // Return default color if edge is not active. + return graphColorDefaultEdge +} diff --git a/spn/navigator/api_route.go b/spn/navigator/api_route.go new file mode 100644 index 00000000..4d854841 --- /dev/null +++ b/spn/navigator/api_route.go @@ -0,0 +1,396 @@ +package navigator + +import ( + "bytes" + "errors" + "fmt" + mrand "math/rand" + "net" + "net/http" + "strings" + "text/tabwriter" + "time" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/config" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/network/netutils" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" +) + +func registerRouteAPIEndpoints() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Path: `spn/map/{map:[A-Za-z0-9]{1,255}}/route/to/{destination:[a-z0-9_\.:-]{1,255}}`, + Read: api.PermitUser, + BelongsTo: module, + ActionFunc: handleRouteCalculationRequest, + Name: "Calculate Route through SPN", + Description: "Returns a textual representation of the routing process.", + Parameters: []api.Parameter{ + { + Method: http.MethodGet, + Field: "profile", + Value: "|global", + Description: "Specify a profile ID to load more settings for simulation.", + }, + { + Method: http.MethodGet, + Field: "encrypted", + Value: "true", + Description: "Specify to signify that the simulated connection should be regarded as encrypted. Only valid with a profile.", + }, + }, + }); err != nil { + return err + } + + return nil +} + +func handleRouteCalculationRequest(ar *api.Request) (msg string, err error) { //nolint:maintidx + // Get map. + m, ok := getMapForAPI(ar.URLVars["map"]) + if !ok { + return "", errors.New("map not found") + } + // Get profile ID. + profileID := ar.Request.URL.Query().Get("profile") + + // Parse destination and prepare options. + entity := &intel.Entity{} + destination := ar.URLVars["destination"] + matchFor := DestinationHub + var ( + introText string + locationV4, locationV6 *geoip.Location + opts *Options + ) + switch { + case destination == "": + // Destination is required. + return "", errors.New("no destination provided") + + case destination == "home": + if profileID != "" { + return "", errors.New("cannot apply profile to home hub route") + } + // Simulate finding home hub. + locations, ok := netenv.GetInternetLocation() + if !ok || len(locations.All) == 0 { + return "", errors.New("failed to locate own device for finding home hub") + } + introText = fmt.Sprintf("looking for home hub near %s and %s", locations.BestV4(), locations.BestV6()) + locationV4 = locations.BestV4().LocationOrNil() + locationV6 = locations.BestV6().LocationOrNil() + matchFor = HomeHub + + // START of copied from captain/navigation.go + + // Get own entity. + // Checking the entity against the entry policies is somewhat hit and miss + // anyway, as the device location is an approximation. + var myEntity *intel.Entity + if dl := locations.BestV4(); dl != nil && dl.IP != nil { + myEntity = (&intel.Entity{IP: dl.IP}).Init(0) + myEntity.FetchData(ar.Context()) + } else if dl := locations.BestV6(); dl != nil && dl.IP != nil { + myEntity = (&intel.Entity{IP: dl.IP}).Init(0) + myEntity.FetchData(ar.Context()) + } + + // Build navigation options for searching for a home hub. + homePolicy, err := endpoints.ParseEndpoints(config.GetAsStringArray("spn/homePolicy", []string{})()) + if err != nil { + return "", fmt.Errorf("failed to parse home hub policy: %w", err) + } + + opts = &Options{ + Home: &HomeHubOptions{ + HubPolicies: []endpoints.Endpoints{homePolicy}, + CheckHubPolicyWith: myEntity, + }, + } + + // Add requirement to only use Safing nodes when not using community nodes. + if !config.GetAsBool("spn/useCommunityNodes", true)() { + opts.Home.RequireVerifiedOwners = []string{"Safing"} + } + + // Require a trusted home node when the routing profile requires less than two hops. + routingProfile := GetRoutingProfile(config.GetAsString(profile.CfgOptionRoutingAlgorithmKey, DefaultRoutingProfileID)()) + if routingProfile.MinHops < 2 { + opts.Home.Regard = opts.Home.Regard.Add(StateTrusted) + } + + // END of copied + + case net.ParseIP(destination) != nil: + entity.IP = net.ParseIP(destination) + + fallthrough + case netutils.IsValidFqdn(destination): + fallthrough + case netutils.IsValidFqdn(destination + "."): + // Resolve domain to IP, if not inherired from a previous case. + var ignoredIPs int + if entity.IP == nil { + entity.Domain = destination + + // Resolve name to IPs. + ips, err := net.DefaultResolver.LookupIP(ar.Context(), "ip", destination) + if err != nil { + return "", fmt.Errorf("failed to lookup IP address of %s: %w", destination, err) + } + if len(ips) == 0 { + return "", fmt.Errorf("failed to lookup IP address of %s: no result", destination) + } + + // Shuffle IPs. + if len(ips) >= 2 { + mr := mrand.New(mrand.NewSource(time.Now().UnixNano())) //nolint:gosec + mr.Shuffle(len(ips), func(i, j int) { + ips[i], ips[j] = ips[j], ips[i] + }) + } + + entity.IP = ips[0] + ignoredIPs = len(ips) - 1 + } + entity.Init(0) + + // Get location of IP. + location, ok := entity.GetLocation(ar.Context()) + if !ok { + return "", fmt.Errorf("failed to get geoip location for %s: %s", entity.IP, entity.LocationError) + } + // Assign location to separate variables. + if entity.IP.To4() != nil { + locationV4 = location + } else { + locationV6 = location + } + + // Set intro text. + if entity.Domain != "" { + introText = fmt.Sprintf("looking for route to %s at %s\n(ignoring %d additional IPs returned by DNS)", entity.IP, formatLocation(location), ignoredIPs) + } else { + introText = fmt.Sprintf("looking for route to %s at %s", entity.IP, formatLocation(location)) + } + + // Get profile. + if profileID != "" { + var lp *profile.LayeredProfile + if profileID == "global" { + // Create new empty profile for easy access to global settings. + lp = profile.NewLayeredProfile(profile.New(nil)) + } else { + // Get local profile by ID. + localProfile, err := profile.GetLocalProfile(profileID, nil, nil) + if err != nil { + return "", fmt.Errorf("failed to get profile: %w", err) + } + lp = localProfile.LayeredProfile() + } + opts = DeriveTunnelOptions( + lp, + entity, + ar.Request.URL.Query().Has("encrypted"), + ) + } else { + opts = m.defaultOptions() + } + + default: + return "", errors.New("invalid destination provided") + } + + // Finalize entity. + entity.Init(0) + + // Start formatting output. + lines := []string{ + "Routing simulation: " + introText, + "Please note that this routing simulation does match the behavior of regular routing to 100%.", + "", + } + + // Print options. + // ================== + + lines = append(lines, "Routing Options:") + lines = append(lines, "Algorithm: "+opts.RoutingProfile) + if opts.Home != nil { + lines = append(lines, "Home Options:") + lines = append(lines, fmt.Sprintf(" Regard: %s", opts.Home.Regard)) + lines = append(lines, fmt.Sprintf(" Disregard: %s", opts.Home.Disregard)) + lines = append(lines, fmt.Sprintf(" No Default: %v", opts.Home.NoDefaults)) + lines = append(lines, fmt.Sprintf(" Hub Policies: %v", opts.Home.HubPolicies)) + lines = append(lines, fmt.Sprintf(" Require Verified Owners: %v", opts.Home.RequireVerifiedOwners)) + } + if opts.Transit != nil { + lines = append(lines, "Transit Options:") + lines = append(lines, fmt.Sprintf(" Regard: %s", opts.Transit.Regard)) + lines = append(lines, fmt.Sprintf(" Disregard: %s", opts.Transit.Disregard)) + lines = append(lines, fmt.Sprintf(" No Default: %v", opts.Transit.NoDefaults)) + lines = append(lines, fmt.Sprintf(" Hub Policies: %v", opts.Transit.HubPolicies)) + lines = append(lines, fmt.Sprintf(" Require Verified Owners: %v", opts.Transit.RequireVerifiedOwners)) + } + if opts.Destination != nil { + lines = append(lines, "Destination Options:") + lines = append(lines, fmt.Sprintf(" Regard: %s", opts.Destination.Regard)) + lines = append(lines, fmt.Sprintf(" Disregard: %s", opts.Destination.Disregard)) + lines = append(lines, fmt.Sprintf(" No Default: %v", opts.Destination.NoDefaults)) + lines = append(lines, fmt.Sprintf(" Hub Policies: %v", opts.Destination.HubPolicies)) + lines = append(lines, fmt.Sprintf(" Require Verified Owners: %v", opts.Destination.RequireVerifiedOwners)) + if opts.Destination.CheckHubPolicyWith != nil { + lines = append(lines, " Check Hub Policy With:") + if opts.Destination.CheckHubPolicyWith.Domain != "" { + lines = append(lines, fmt.Sprintf(" Domain: %v", opts.Destination.CheckHubPolicyWith.Domain)) + } + if opts.Destination.CheckHubPolicyWith.IP != nil { + lines = append(lines, fmt.Sprintf(" IP: %v", opts.Destination.CheckHubPolicyWith.IP)) + } + if opts.Destination.CheckHubPolicyWith.Port != 0 { + lines = append(lines, fmt.Sprintf(" Port: %v", opts.Destination.CheckHubPolicyWith.Port)) + } + } + } + lines = append(lines, "\n") + + // Find nearest hubs. + // ================== + + // Start operating in map. + m.RLock() + defer m.RUnlock() + // Check if map is populated. + if m.isEmpty() { + return "", ErrEmptyMap + } + + // Find nearest hubs. + nbPins, err := m.findNearestPins(locationV4, locationV6, opts, matchFor, true) + if err != nil { + lines = append(lines, fmt.Sprintf("FAILED to find any suitable exit hub: %s", err)) + return strings.Join(lines, "\n"), nil + // return "", fmt.Errorf("failed to search for nearby pins: %w", err) + } + + // Print found exits to table. + lines = append(lines, "Considered Exits (cheapest 10% are shuffled)") + buf := bytes.NewBuffer(nil) + tabWriter := tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0) + fmt.Fprint(tabWriter, "Hub Name\tCost\tLocation\n") + for _, nbPin := range nbPins.pins { + fmt.Fprintf(tabWriter, + "%s\t%.0f\t%s\n", + nbPin.pin.Hub.Name(), + nbPin.cost, + formatMultiLocation(nbPin.pin.LocationV4, nbPin.pin.LocationV6), + ) + } + _ = tabWriter.Flush() + lines = append(lines, buf.String()) + + // Print too expensive exits to table. + lines = append(lines, "Too Expensive Exits:") + buf = bytes.NewBuffer(nil) + tabWriter = tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0) + fmt.Fprint(tabWriter, "Hub Name\tCost\tLocation\n") + for _, nbPin := range nbPins.debug.tooExpensive { + fmt.Fprintf(tabWriter, + "%s\t%.0f\t%s\n", + nbPin.pin.Hub.Name(), + nbPin.cost, + formatMultiLocation(nbPin.pin.LocationV4, nbPin.pin.LocationV6), + ) + } + _ = tabWriter.Flush() + lines = append(lines, buf.String()) + + // Print disregarded exits to table. + lines = append(lines, "Disregarded Exits:") + buf = bytes.NewBuffer(nil) + tabWriter = tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0) + fmt.Fprint(tabWriter, "Hub Name\tReason\tStates\n") + for _, nbPin := range nbPins.debug.disregarded { + fmt.Fprintf(tabWriter, + "%s\t%s\t%s\n", + nbPin.pin.Hub.Name(), + nbPin.reason, + nbPin.pin.State, + ) + } + _ = tabWriter.Flush() + lines = append(lines, buf.String()) + + // Find routes. + // ============ + + // Unless we looked for a home node. + if destination == "home" { + return strings.Join(lines, "\n"), nil + } + + // Find routes. + routes, err := m.findRoutes(nbPins, opts) + if err != nil { + lines = append(lines, fmt.Sprintf("FAILED to find routes: %s", err)) + return strings.Join(lines, "\n"), nil + // return "", fmt.Errorf("failed to find routes: %w", err) + } + + // Print found routes to table. + lines = append(lines, "Considered Routes (cheapest 10% are shuffled)") + buf = bytes.NewBuffer(nil) + tabWriter = tabwriter.NewWriter(buf, 8, 4, 3, ' ', 0) + fmt.Fprint(tabWriter, "Cost\tPath\n") + for _, route := range routes.All { + fmt.Fprintf(tabWriter, + "%.0f\t%s\n", + route.TotalCost, + formatRoute(route, entity.IP), + ) + } + _ = tabWriter.Flush() + lines = append(lines, buf.String()) + + return strings.Join(lines, "\n"), nil +} + +func formatLocation(loc *geoip.Location) string { + return fmt.Sprintf( + "%s (%s - AS%d %s)", + loc.Country.Name, + loc.Country.Code, + loc.AutonomousSystemNumber, + loc.AutonomousSystemOrganization, + ) +} + +func formatMultiLocation(a, b *geoip.Location) string { + switch { + case a != nil: + return formatLocation(a) + case b != nil: + return formatLocation(b) + default: + return "" + } +} + +func formatRoute(r *Route, dst net.IP) string { + s := make([]string, 0, len(r.Path)+1) + for i, hop := range r.Path { + if i == 0 { + s = append(s, hop.pin.Hub.Name()) + } else { + s = append(s, fmt.Sprintf(">> %.2fc >> %s", hop.Cost, hop.pin.Hub.Name())) + } + } + s = append(s, fmt.Sprintf(">> %.2fc >> %s", r.DstCost, dst)) + return strings.Join(s, " ") +} diff --git a/spn/navigator/costs.go b/spn/navigator/costs.go new file mode 100644 index 00000000..0b48ea16 --- /dev/null +++ b/spn/navigator/costs.go @@ -0,0 +1,72 @@ +package navigator + +import "time" + +const ( + nearestPinsMaxCostDifference = 5000 + nearestPinsMinimum = 10 +) + +// CalculateLaneCost calculates the cost of using a Lane based on the given +// Lane latency and capacity. +// Ranges from 0 to 10000. +func CalculateLaneCost(latency time.Duration, capacity int) (cost float32) { + // - One point for every ms in latency (linear) + if latency != 0 { + cost += float32(latency) / float32(time.Millisecond) + } else { + // Add cautious default cost if latency is not available. + cost += 1000 + } + + capacityFloat := float32(capacity) + switch { + case capacityFloat == 0: + // Add cautious default cost if capacity is not available. + cost += 4000 + case capacityFloat < cap1Mbit: + // - Between 1000 and 10000 points for ranges below 1Mbit/s + cost += 1000 + 9000*((cap1Mbit-capacityFloat)/cap1Mbit) + case capacityFloat < cap10Mbit: + // - Between 100 and 1000 points for ranges below 10Mbit/s + cost += 100 + 900*((cap10Mbit-capacityFloat)/cap10Mbit) + case capacityFloat < cap100Mbit: + // - Between 20 and 100 points for ranges below 100Mbit/s + cost += 20 + 80*((cap100Mbit-capacityFloat)/cap100Mbit) + case capacityFloat < cap1Gbit: + // - Between 5 and 20 points for ranges below 1Gbit/s + cost += 5 + 15*((cap1Gbit-capacityFloat)/cap1Gbit) + case capacityFloat < cap10Gbit: + // - Between 0 and 5 points for ranges below 10Gbit/s + cost += 5 * ((cap10Gbit - float32(capacity)) / cap10Gbit) + } + + return cost +} + +// CalculateHubCost calculates the cost of using a Hub based on the given Hub load. +// Ranges from 100 to 10000. +func CalculateHubCost(load int) (cost float32) { + switch { + case load >= 100: + return 10000 + case load >= 95: + return 1000 + case load >= 80: + return 500 + default: + return 100 + } +} + +// CalculateDestinationCost calculates the cost of a destination hub to a +// destination server based on the given proximity. +// Ranges from 0 to 2500. +func CalculateDestinationCost(proximity float32) (cost float32) { + // Invert from proximity (0-100) to get a distance value. + distance := 100 - proximity + + // Take the distance to the power of three and then divide by hundred in order to + // make high distances exponentially more expensive. + return (distance * distance * distance) / 100 +} diff --git a/spn/navigator/database.go b/spn/navigator/database.go new file mode 100644 index 00000000..b7ee8ae4 --- /dev/null +++ b/spn/navigator/database.go @@ -0,0 +1,164 @@ +package navigator + +import ( + "context" + "fmt" + "strings" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/iterator" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/database/storage" +) + +var mapDBController *database.Controller + +// StorageInterface provices a storage.Interface to the +// configuration manager. +type StorageInterface struct { + storage.InjectBase +} + +// Database prefixes: +// Pins: map:main/ +// DNS Requests: network:tree//dns/ +// IP Connections: network:tree//ip/ + +func makeDBKey(mapName, hubID string) string { + return fmt.Sprintf("map:%s/%s", mapName, hubID) +} + +func parseDBKey(key string) (mapName, hubID string) { + // Split into segments. + segments := strings.Split(key, "/") + + // Keys have 1 or 2 segments. + switch len(segments) { + case 1: + return segments[0], "" + case 2: + return segments[0], segments[1] + default: + return "", "" + } +} + +// Get returns a database record. +func (s *StorageInterface) Get(key string) (record.Record, error) { + // Parse key and check if valid. + mapName, hubID := parseDBKey(key) + if mapName == "" || hubID == "" { + return nil, storage.ErrNotFound + } + + // Get map. + m, ok := getMapForAPI(mapName) + if !ok { + return nil, storage.ErrNotFound + } + + // Get Pin from map. + pin, ok := m.GetPin(hubID) + if !ok { + return nil, storage.ErrNotFound + } + return pin.Export(), nil +} + +// Query returns a an iterator for the supplied query. +func (s *StorageInterface) Query(q *query.Query, local, internal bool) (*iterator.Iterator, error) { + // Parse key and check if valid. + mapName, _ := parseDBKey(q.DatabaseKeyPrefix()) + if mapName == "" { + return nil, storage.ErrNotFound + } + + // Get map. + m, ok := getMapForAPI(mapName) + if !ok { + return nil, storage.ErrNotFound + } + + // Start query worker. + it := iterator.New() + module.StartWorker("map query", func(_ context.Context) error { + s.processQuery(m, q, it) + return nil + }) + + return it, nil +} + +func (s *StorageInterface) processQuery(m *Map, q *query.Query, it *iterator.Iterator) { + // Return all matching pins. + for _, pin := range m.sortedPins(true) { + export := pin.Export() + if q.Matches(export) { + select { + case it.Next <- export: + case <-it.Done: + return + } + } + } + + it.Finish(nil) +} + +func registerMapDatabase() error { + _, err := database.Register(&database.Database{ + Name: "map", + Description: "SPN Network Maps", + StorageType: database.StorageTypeInjected, + }) + if err != nil { + return err + } + + controller, err := database.InjectDatabase("map", &StorageInterface{}) + if err != nil { + return err + } + + mapDBController = controller + return nil +} + +func withdrawMapDatabase() { + mapDBController.Withdraw() +} + +// PushPinChanges pushes all changed pins to subscribers. +func (m *Map) PushPinChanges() { + module.StartWorker("push pin changes", m.pushPinChangesWorker) +} + +func (m *Map) pushPinChangesWorker(ctx context.Context) error { + m.RLock() + defer m.RUnlock() + + for _, pin := range m.all { + if pin.pushChanges.SetToIf(true, false) { + mapDBController.PushUpdate(pin.Export()) + } + } + + return nil +} + +// pushChange pushes changes of the pin, if the pushChanges flag is set. +func (pin *Pin) pushChange() { + // Check before starting the worker. + if pin.pushChanges.IsNotSet() { + return + } + + // Start worker to push changes. + module.StartWorker("push pin change", func(ctx context.Context) error { + if pin.pushChanges.SetToIf(true, false) { + mapDBController.PushUpdate(pin.Export()) + } + return nil + }) +} diff --git a/spn/navigator/findnearest.go b/spn/navigator/findnearest.go new file mode 100644 index 00000000..0a294ce2 --- /dev/null +++ b/spn/navigator/findnearest.go @@ -0,0 +1,441 @@ +package navigator + +import ( + "errors" + "fmt" + mrand "math/rand" + "sort" + "strings" + "time" + + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/spn/hub" +) + +const ( + // defaultMaxNearbyMatches defines a default value of how many matches a + // nearby pin find operation in a map should return. + defaultMaxNearbyMatches = 100 + + // defaultRandomizeNearbyPinTopPercent defines the top percent of a nearby + // pins set that should be randomized for balancing purposes. + // Range: 0-1. + defaultRandomizeNearbyPinTopPercent = 0.1 +) + +// nearbyPins is a list of nearby Pins to a certain location. +type nearbyPins struct { + pins []*nearbyPin + minPins int + maxPins int + maxCost float32 + cutOffLimit float32 + randomizeTopPercent float32 + + debug *nearbyPinsDebug +} + +// nearbyPinsDebug holds additional debugging for nearbyPins. +type nearbyPinsDebug struct { + tooExpensive []*nearbyPin + disregarded []*nearbyDisregardedPin +} + +// nearbyDisregardedPin represents a disregarded pin. +type nearbyDisregardedPin struct { + pin *Pin + reason string +} + +// nearbyPin represents a Pin and the proximity to a certain location. +type nearbyPin struct { + pin *Pin + cost float32 +} + +// Len is the number of elements in the collection. +func (nb *nearbyPins) Len() int { + return len(nb.pins) +} + +// Less reports whether the element with index i should sort before the element +// with index j. +func (nb *nearbyPins) Less(i, j int) bool { + return nb.pins[i].cost < nb.pins[j].cost +} + +// Swap swaps the elements with indexes i and j. +func (nb *nearbyPins) Swap(i, j int) { + nb.pins[i], nb.pins[j] = nb.pins[j], nb.pins[i] +} + +// add potentially adds a Pin to the list of nearby Pins. +func (nb *nearbyPins) add(pin *Pin, cost float32) { + if len(nb.pins) > nb.minPins && nb.maxCost > 0 && cost > nb.maxCost { + // Add debug data if enabled. + if nb.debug != nil { + nb.debug.tooExpensive = append(nb.debug.tooExpensive, + &nearbyPin{ + pin: pin, + cost: cost, + }, + ) + } + + return + } + + nb.pins = append(nb.pins, &nearbyPin{ + pin: pin, + cost: cost, + }) +} + +// contains checks if the collection contains a Pin. +func (nb *nearbyPins) get(id string) *nearbyPin { + for _, nbPin := range nb.pins { + if nbPin.pin.Hub.ID == id { + return nbPin + } + } + + return nil +} + +// clean sort and shortens the list to the configured maximum. +func (nb *nearbyPins) clean() { + // Sort nearby Pins so that the closest one is on top. + sort.Sort(nb) + + // Set maximum cost based on max difference, if we have enough pins. + if len(nb.pins) >= nb.minPins { + nb.maxCost = nb.pins[0].cost + nb.cutOffLimit + } + + // Remove superfluous Pins from the list. + if len(nb.pins) > nb.maxPins { + // Add debug data if enabled. + if nb.debug != nil { + nb.debug.tooExpensive = append(nb.debug.tooExpensive, nb.pins[nb.maxPins:]...) + } + + nb.pins = nb.pins[:nb.maxPins] + } + // Remove Pins that are too costly. + if len(nb.pins) > nb.minPins { + // Search for first pin that is too costly. + okUntil := nb.minPins + for ; okUntil < len(nb.pins); okUntil++ { + if nb.pins[okUntil].cost > nb.maxCost { + break + } + } + + // Add debug data if enabled. + if nb.debug != nil { + nb.debug.tooExpensive = append(nb.debug.tooExpensive, nb.pins[okUntil:]...) + } + + // Cut off the list at that point. + nb.pins = nb.pins[:okUntil] + } +} + +// randomizeTop randomized to the top nearest pins for balancing the network. +func (nb *nearbyPins) randomizeTop() { + switch { + case nb.randomizeTopPercent == 0: + // Check if randomization is enabled. + return + case len(nb.pins) < 2: + // Check if we have enough pins to work with. + return + } + + // Find randomization set. + randomizeUpTo := len(nb.pins) + threshold := nb.pins[0].cost * (1 + nb.randomizeTopPercent) + for i, nb := range nb.pins { + // Find first value above the threshold to stop. + if nb.cost > threshold { + randomizeUpTo = i + break + } + } + + // Shuffle top set. + if randomizeUpTo >= 2 { + mr := mrand.New(mrand.NewSource(time.Now().UnixNano())) //nolint:gosec + mr.Shuffle(randomizeUpTo, nb.Swap) + } +} + +// FindNearestHubs searches for the nearest Hubs to the given IP address. The returned Hubs must not be modified in any way. +func (m *Map) FindNearestHubs(locationV4, locationV6 *geoip.Location, opts *Options, matchFor HubType) ([]*hub.Hub, error) { + m.RLock() + defer m.RUnlock() + + // Check if map is populated. + if m.isEmpty() { + return nil, ErrEmptyMap + } + + // Set default options if unset. + if opts == nil { + opts = m.defaultOptions() + } + + // Find nearest Pins. + nearby, err := m.findNearestPins(locationV4, locationV6, opts, matchFor, false) + if err != nil { + return nil, err + } + + // Convert to Hub list and return. + hubs := make([]*hub.Hub, 0, len(nearby.pins)) + for _, nbPin := range nearby.pins { + hubs = append(hubs, nbPin.pin.Hub) + } + return hubs, nil +} + +func (m *Map) findNearestPins(locationV4, locationV6 *geoip.Location, opts *Options, matchFor HubType, debug bool) (*nearbyPins, error) { + // Fail if no location is provided. + if locationV4 == nil && locationV6 == nil { + return nil, errors.New("no location provided") + } + + // Raise maxMatches to nearestPinsMinimum. + maxMatches := defaultMaxNearbyMatches + if maxMatches < nearestPinsMinimum { + maxMatches = nearestPinsMinimum + } + + // Create nearby Pins list. + nearby := &nearbyPins{ + minPins: nearestPinsMinimum, + maxPins: maxMatches, + cutOffLimit: nearestPinsMaxCostDifference, + randomizeTopPercent: defaultRandomizeNearbyPinTopPercent, + } + if debug { + nearby.debug = &nearbyPinsDebug{} + } + + // Create pin matcher. + matcher := opts.Matcher(matchFor, m.intel) + + // Iterate over all Pins in the Map to find the nearest ones. + for _, pin := range m.all { + var cost float32 + + // Check if the Pin matches the criteria. + if !matcher(pin) { + // Add debug data if enabled. + if nearby.debug != nil && pin.State.Has(StateActive|StateReachable) { + nearby.debug.disregarded = append(nearby.debug.disregarded, + &nearbyDisregardedPin{ + pin: pin, + reason: "does not match general criteria", + }, + ) + } + + // Debugging: + // log.Tracef("spn/navigator: skipping %s with states %s for finding nearest", pin, pin.State) + continue + } + + // Check if the Hub supports at least one IP version we are looking for. + switch { + case locationV4 != nil && pin.LocationV4 != nil: + // Both have IPv4! + case locationV6 != nil && pin.LocationV6 != nil: + // Both have IPv6! + default: + // Hub does not support any IP version we need. + + // Add debug data if enabled. + if nearby.debug != nil { + nearby.debug.disregarded = append(nearby.debug.disregarded, + &nearbyDisregardedPin{ + pin: pin, + reason: "does not support the required IP version", + }, + ) + } + + continue + } + + // If finding a home hub and the global routing profile is set to home ("VPN"), + // check if all local IP versions are available on the Hub. + if matchFor == HomeHub && cfgOptionRoutingAlgorithm() == RoutingProfileHomeID { + switch { + case locationV4 != nil && pin.LocationV4 == nil: + // Device has IPv4, but Hub does not! + fallthrough + case locationV6 != nil && pin.LocationV6 == nil: + // Device has IPv6, but Hub does not! + + // Add debug data if enabled. + if nearby.debug != nil { + nearby.debug.disregarded = append(nearby.debug.disregarded, + &nearbyDisregardedPin{ + pin: pin, + reason: "home hub needs all IP versions of client (when Home/VPN routing)", + }, + ) + } + + continue + } + } + + // 1. Calculate cost based on distance + + if locationV4 != nil && pin.LocationV4 != nil { + if locationV4.IsAnycast && m.home != nil { + // If the destination is anycast, calculate cost though proximity to home hub instead, if possible. + cost = lessButPositive(cost, CalculateDestinationCost( + proximityBetweenPins(pin, m.home), + )) + } else { + // Regular cost calculation through proximity. + cost = lessButPositive(cost, CalculateDestinationCost( + locationV4.EstimateNetworkProximity(pin.LocationV4), + )) + } + } + + if locationV6 != nil && pin.LocationV6 != nil { + if locationV6.IsAnycast && m.home != nil { + // If the destination is anycast, calculate cost though proximity to home hub instead, if possible. + cost = lessButPositive(cost, CalculateDestinationCost( + proximityBetweenPins(pin, m.home), + )) + } else { + // Regular cost calculation through proximity. + cost = lessButPositive(cost, CalculateDestinationCost( + locationV6.EstimateNetworkProximity(pin.LocationV6), + )) + } + } + + // If no cost could be calculated, fall back to a default value. + if cost == 0 { + cost = CalculateDestinationCost(50) // proximity out of 0-100 + } + + // Debugging: + // if matchFor == HomeHub { + // log.Tracef("spn/navigator: adding %.2f proximity cost to home hub %s", cost, pin.Hub) + // } + + // 2. Add cost based on Hub status + + cost += CalculateHubCost(pin.Hub.Status.Load) + + // Debugging: + // if matchFor == HomeHub { + // log.Tracef("spn/navigator: adding %.2f hub cost to home hub %s", CalculateHubCost(pin.Hub.Status.Load), pin.Hub) + // } + + // 3. If matching a home hub, add cost based on capacity/latency performance. + + if matchFor == HomeHub { + // Find best capacity/latency values. + var ( + bestCapacity int + bestLatency time.Duration + ) + for _, lane := range pin.Hub.Status.Lanes { + if lane.Capacity > bestCapacity { + bestCapacity = lane.Capacity + } + if bestLatency == 0 || lane.Latency < bestLatency { + bestLatency = lane.Latency + } + } + // Add cost of best capacity/latency values. + cost += CalculateLaneCost(bestLatency, bestCapacity) + + // Debugging: + // log.Tracef("spn/navigator: adding %.2f lane cost to home hub %s", CalculateLaneCost(bestLatency, bestCapacity), pin.Hub) + // log.Debugf("spn/navigator: total cost of %.2f to home hub %s", cost, pin.Hub) + } + + nearby.add(pin, cost) + + // Clean the nearby list if have collected more than two times the max amount. + if len(nearby.pins) >= nearby.maxPins*2 { + nearby.clean() + } + } + + // Check if we found any nearby pins + if nearby.Len() == 0 { + return nil, ErrAllPinsDisregarded + } + + // Clean one last time and return the list. + nearby.clean() + + // Randomize top nearest pins for load balancing. + nearby.randomizeTop() + + // Debugging: + // if matchFor == HomeHub { + // log.Debug("spn/navigator: nearby pins:") + // for _, nbPin := range nearby.pins { + // log.Debugf("spn/navigator: nearby pin %s", nbPin) + // } + // } + + return nearby, nil +} + +func (nb *nearbyPins) String() string { + s := make([]string, 0, len(nb.pins)) + for _, nbPin := range nb.pins { + s = append(s, nbPin.String()) + } + return strings.Join(s, ", ") +} + +func (nb *nearbyPin) String() string { + return fmt.Sprintf("%s at %.2fc", nb.pin, nb.cost) +} + +func proximityBetweenPins(a, b *Pin) float32 { + var x, y float32 + + // Get IPv4 network proximity. + if a.LocationV4 != nil && b.LocationV4 != nil { + x = a.LocationV4.EstimateNetworkProximity(b.LocationV4) + } + + // Get IPv6 network proximity. + if a.LocationV6 != nil && b.LocationV6 != nil { + y = a.LocationV6.EstimateNetworkProximity(b.LocationV6) + } + + // Return higher proximity. + if x > y { + return x + } + return y +} + +func lessButPositive(a, b float32) float32 { + switch { + case a == 0: + return b + case b == 0: + return a + case a < b: + return a + default: + return b + } +} diff --git a/spn/navigator/findnearest_test.go b/spn/navigator/findnearest_test.go new file mode 100644 index 00000000..596d7779 --- /dev/null +++ b/spn/navigator/findnearest_test.go @@ -0,0 +1,124 @@ +package navigator + +import ( + "testing" +) + +func TestFindNearest(t *testing.T) { + t.Parallel() + + // Create map and lock faking in order to guarantee reproducability of faked data. + m := getDefaultTestMap() + fakeLock.Lock() + defer fakeLock.Unlock() + + for i := 0; i < 100; i++ { + // Create a random destination address + ip4, loc4 := createGoodIP(true) + + nbPins, err := m.findNearestPins(loc4, nil, m.DefaultOptions(), DestinationHub, false) + if err != nil { + t.Error(err) + } else { + t.Logf("Pins near %s: %s", ip4, nbPins) + } + } + + for i := 0; i < 100; i++ { + // Create a random destination address + ip6, loc6 := createGoodIP(true) + + nbPins, err := m.findNearestPins(nil, loc6, m.DefaultOptions(), DestinationHub, false) + if err != nil { + t.Error(err) + } else { + t.Logf("Pins near %s: %s", ip6, nbPins) + } + } +} + +/* +TODO: Find a way to quickly generate good geoip data on the fly, as we don't want to measure IP address generation, but only finding the nearest pins. + +func BenchmarkFindNearest(b *testing.B) { + // Create map and lock faking in order to guarantee reproducability of faked data. + m := getDefaultTestMap() + fakeLock.Lock() + defer fakeLock.Unlock() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Create a random destination address + var dstIP net.IP + if i%2 == 0 { + dstIP = net.ParseIP(gofakeit.IPv4Address()) + } else { + dstIP = net.ParseIP(gofakeit.IPv6Address()) + } + + _, err := m.findNearestPins(dstIP, m.DefaultOptions(),DestinationHub if err != nil { + b.Error(err) + } + } +} +*/ + +func findFakeHomeHub(m *Map) { + // Create fake IP address. + _, loc4 := createGoodIP(true) + _, loc6 := createGoodIP(false) + + nbPins, err := m.findNearestPins(loc4, loc6, m.defaultOptions(), HomeHub, false) + if err != nil { + panic(err) + } + if len(nbPins.pins) == 0 { + panic("could not find a Home Hub") + } + + // Set Home. + m.home = nbPins.pins[0].pin + + // Recalculate reachability. + if err := m.recalculateReachableHubs(); err != nil { + panic(err) + } +} + +func TestNearbyPinsCleaning(t *testing.T) { + t.Parallel() + + testCleaning(t, []float32{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}, 3) + testCleaning(t, []float32{10, 11, 12, 13, 50, 60, 70, 80, 90, 100}, 4) + testCleaning(t, []float32{10, 11, 12, 40, 50, 60, 70, 80, 90, 100}, 3) + testCleaning(t, []float32{10, 11, 30, 40, 50, 60, 70, 80, 90, 100}, 3) +} + +func testCleaning(t *testing.T, costs []float32, expectedLeftOver int) { + t.Helper() + + nb := &nearbyPins{ + minPins: 3, + maxPins: 5, + cutOffLimit: 10, + } + + // Simulate usage. + for _, cost := range costs { + // Add to list. + nb.add(nil, cost) + + // Clean once in a while. + if len(nb.pins) > nb.maxPins { + nb.clean() + } + } + // Final clean. + nb.clean() + + // Check results. + t.Logf("result: %+v", nb.pins) + if len(nb.pins) != expectedLeftOver { + t.Errorf("unexpected amount of left over pins: %+v", nb.pins) + } +} diff --git a/spn/navigator/findroutes.go b/spn/navigator/findroutes.go new file mode 100644 index 00000000..ef886334 --- /dev/null +++ b/spn/navigator/findroutes.go @@ -0,0 +1,234 @@ +package navigator + +import ( + "errors" + "fmt" + "net" + + "github.com/safing/portmaster/service/intel/geoip" +) + +const ( + // defaultMaxRouteMatches defines a default value of how many matches a + // route find operation in a map should return. + defaultMaxRouteMatches = 10 + + // defaultRandomizeRoutesTopPercent defines the top percent of a routes + // set that should be randomized for balancing purposes. + // Range: 0-1. + defaultRandomizeRoutesTopPercent = 0.1 +) + +// FindRoutes finds possible routes to the given IP, with the given options. +func (m *Map) FindRoutes(ip net.IP, opts *Options) (*Routes, error) { + m.Lock() + defer m.Unlock() + + // Check if map is populated. + if m.isEmpty() { + return nil, ErrEmptyMap + } + + // Check if home hub is set. + if m.home == nil { + return nil, ErrHomeHubUnset + } + + // Get the location of the given IP address. + var locationV4, locationV6 *geoip.Location + var err error + // Save whether the given IP address is a IPv4 or IPv6 address. + if v4 := ip.To4(); v4 != nil { + locationV4, err = geoip.GetLocation(ip) + } else { + locationV6, err = geoip.GetLocation(ip) + } + if err != nil { + return nil, fmt.Errorf("failed to get IP location: %w", err) + } + + // Set default options if unset. + if opts == nil { + opts = m.defaultOptions() + } + + // Handle special home routing profile. + if opts.RoutingProfile == RoutingProfileHomeID { + switch { + case locationV4 != nil && m.home.LocationV4 == nil: + // Destination is IPv4, but Hub has no IPv4! + // Upgrade routing profile. + opts.RoutingProfile = RoutingProfileSingleHopID + + case locationV6 != nil && m.home.LocationV6 == nil: + // Destination is IPv6, but Hub has no IPv6! + // Upgrade routing profile. + opts.RoutingProfile = RoutingProfileSingleHopID + + default: + // Return route with only home hub for home hub routing. + return &Routes{ + All: []*Route{{ + Path: []*Hop{{ + pin: m.home, + HubID: m.home.Hub.ID, + }}, + Algorithm: RoutingProfileHomeID, + }}, + }, nil + } + } + + // Find nearest Pins. + nearby, err := m.findNearestPins(locationV4, locationV6, opts, DestinationHub, false) + if err != nil { + return nil, err + } + + return m.findRoutes(nearby, opts) +} + +// FindRouteToHub finds possible routes to the given Hub, with the given options. +func (m *Map) FindRouteToHub(hubID string, opts *Options) (*Routes, error) { + m.Lock() + defer m.Unlock() + + // Get Pin. + pin, ok := m.all[hubID] + if !ok { + return nil, ErrHubNotFound + } + + // Create a nearby with a single Pin. + nearby := &nearbyPins{ + pins: []*nearbyPin{ + { + pin: pin, + }, + }, + } + + // Find a route to the given Hub. + return m.findRoutes(nearby, opts) +} + +func (m *Map) findRoutes(dsts *nearbyPins, opts *Options) (*Routes, error) { + if m.home == nil { + return nil, ErrHomeHubUnset + } + + // Initialize matchers. + var done bool + transitMatcher := opts.Transit.Matcher(m.intel) + destinationMatcher := opts.Destination.Matcher(m.intel) + routingProfile := GetRoutingProfile(opts.RoutingProfile) + + // Create routes collector. + routes := &Routes{ + maxRoutes: defaultMaxRouteMatches, + randomizeTopPercent: defaultRandomizeRoutesTopPercent, + } + + // TODO: + // Start from the destination and use HopDistance to prioritize + // exploring routes that are in the right direction. + // How would we handle selecting the destination node based on route to client? + // Should we just try all destinations? + + // Create initial route. + route := &Route{ + // Estimate how much space we will need, else it'll just expand. + Path: make([]*Hop, 1, routingProfile.MinHops+routingProfile.MaxExtraHops), + } + route.Path[0] = &Hop{ + pin: m.home, + // TODO: add initial cost + } + + // exploreHop explores a hop (Lane) to a connected Pin. + var exploreHop func(route *Route, lane *Lane) + + // exploreLanes explores all Lanes of a Pin. + exploreLanes := func(route *Route) { + for _, lane := range route.Path[len(route.Path)-1].pin.ConnectedTo { + // Check if we are done and can skip the rest. + if done { + return + } + + // Explore! + exploreHop(route, lane) + } + } + + exploreHop = func(route *Route, lane *Lane) { + // Check if the Pin should be regarded as Transit Hub. + if !transitMatcher(lane.Pin) { + return + } + + // Add Pin to the current path and remove when done. + route.addHop(lane.Pin, lane.Cost+lane.Pin.Cost) + defer route.removeHop() + + // Check if the route would even make it into the list. + if !routes.isGoodEnough(route) { + return + } + + // Check route compliance. + // This also includes some algorithm-based optimizations. + switch routingProfile.checkRouteCompliance(route, routes) { + case routeOk: + // Route would be compliant. + // Now, check if the last hop qualifies as a Destination Hub. + if destinationMatcher(lane.Pin) { + // Get Pin as nearby Pin. + nbPin := dsts.get(lane.Pin.Hub.ID) + if nbPin != nil { + // Pin is listed as selected Destination Hub! + // Complete route to add destination ("last mile") cost. + route.completeRoute(nbPin.cost) + routes.add(route) + + // We have found a route and have come to an end here. + return + } + } + + // The Route is compliant, but we haven't found a Destination Hub yet. + fallthrough + case routeNonCompliant: + // Continue exploration. + exploreLanes(route) + case routeDisqualified: + fallthrough + default: + // Route is disqualified and we can return without further exploration. + } + } + + // Start the hop exploration tree. + // This will fork into about a gazillion branches and add all the found valid + // routes to the list. + exploreLanes(route) + + // Check if we found anything. + if len(routes.All) == 0 { + return nil, errors.New("failed to find any routes") + } + + // Randomize top routes for load balancing. + routes.randomizeTop() + + // Copy remaining data to routes. + routes.makeExportReady(opts.RoutingProfile) + + // Debugging: + // log.Debug("spn/navigator: routes:") + // for _, route := range routes.All { + // log.Debugf("spn/navigator: %s", route) + // } + + return routes, nil +} diff --git a/spn/navigator/findroutes_test.go b/spn/navigator/findroutes_test.go new file mode 100644 index 00000000..ed7793c1 --- /dev/null +++ b/spn/navigator/findroutes_test.go @@ -0,0 +1,54 @@ +package navigator + +import ( + "net" + "testing" +) + +func TestFindRoutes(t *testing.T) { + t.Parallel() + + // Create map and lock faking in order to guarantee reproducability of faked data. + m := getOptimizedDefaultTestMap(t) + fakeLock.Lock() + defer fakeLock.Unlock() + + for i := 0; i < 1; i++ { + // Create a random destination address + dstIP, _ := createGoodIP(i%2 == 0) + + routes, err := m.FindRoutes(dstIP, m.DefaultOptions()) + switch { + case err != nil: + t.Error(err) + case len(routes.All) == 0: + t.Logf("No routes for %s", dstIP) + default: + t.Logf("Best route for %s: %s", dstIP, routes.All[0]) + } + } +} + +func BenchmarkFindRoutes(b *testing.B) { + // Create map and lock faking in order to guarantee reproducability of faked data. + m := getOptimizedDefaultTestMap(nil) + fakeLock.Lock() + defer fakeLock.Unlock() + + // Pre-generate 100 IPs + preGenIPs := make([]net.IP, 0, 100) + for i := 0; i < cap(preGenIPs); i++ { + ip, _ := createGoodIP(i%2 == 0) + preGenIPs = append(preGenIPs, ip) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + routes, err := m.FindRoutes(preGenIPs[i%len(preGenIPs)], m.DefaultOptions()) + if err != nil { + b.Error(err) + } else { + b.Logf("Best route for %s: %s", preGenIPs[i%len(preGenIPs)], routes.All[0]) + } + } +} diff --git a/spn/navigator/intel.go b/spn/navigator/intel.go new file mode 100644 index 00000000..d26733c1 --- /dev/null +++ b/spn/navigator/intel.go @@ -0,0 +1,222 @@ +package navigator + +import ( + "context" + "errors" + + "golang.org/x/exp/slices" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/hub" +) + +// UpdateIntel supplies the map with new intel data. The data is not copied, so +// it must not be modified after being supplied. If the map is empty, the +// bootstrap hubs will be added to the map. +func (m *Map) UpdateIntel(update *hub.Intel, trustNodes []string) error { + // Check if intel data is already parsed. + if update.Parsed() == nil { + return errors.New("intel data is not parsed") + } + + m.Lock() + defer m.Unlock() + + // Update the map's reference to the intel data. + m.intel = update + + // Update pins with new intel data. + for _, pin := range m.all { + // Add/Update location data from IP addresses. + pin.updateLocationData() + + // Override Pin Data. + m.updateInfoOverrides(pin) + + // Update Trust and Advisory Statuses. + m.updateIntelStatuses(pin, trustNodes) + + // Push changes. + // TODO: Only set when pin changed. + pin.pushChanges.Set() + } + + // Configure the map's regions. + m.updateRegions(m.intel.Regions) + + // Push pin changes. + m.PushPinChanges() + + log.Infof("spn/navigator: updated intel on map %s", m.Name) + + // Add bootstrap hubs if map is empty. + if m.isEmpty() { + return m.addBootstrapHubs(m.intel.BootstrapHubs) + } + return nil +} + +// GetIntel returns the map's intel data. +func (m *Map) GetIntel() *hub.Intel { + m.RLock() + defer m.RUnlock() + + return m.intel +} + +func (m *Map) updateIntelStatuses(pin *Pin, trustNodes []string) { + // Reset all related states. + pin.removeStates(StateTrusted | StateUsageDiscouraged | StateUsageAsHomeDiscouraged | StateUsageAsDestinationDiscouraged) + + // Check if Intel data is loaded. + if m.intel == nil { + return + } + + // Check Hub Intel + hubIntel, ok := m.intel.Hubs[pin.Hub.ID] + if ok { + // Apply the verified owner, if any. + pin.VerifiedOwner = hubIntel.VerifiedOwner + + // Check if Hub is discontinued. + if hubIntel.Discontinued { + // Reset state, set offline and return. + pin.State = StateNone + pin.addStates(StateOffline) + return + } + + // Check if Hub is trusted. + if hubIntel.Trusted { + pin.addStates(StateTrusted) + } + } + + // Check manual trust status. + switch { + case slices.Contains[[]string, string](trustNodes, pin.VerifiedOwner): + pin.addStates(StateTrusted) + case slices.Contains[[]string, string](trustNodes, pin.Hub.ID): + pin.addStates(StateTrusted) + } + + // Check advisories. + // Check for UsageDiscouraged. + checkStatusList( + pin, + StateUsageDiscouraged, + m.intel.AdviseOnlyTrustedHubs, + m.intel.Parsed().HubAdvisory, + ) + // Check for UsageAsHomeDiscouraged. + checkStatusList( + pin, + StateUsageAsHomeDiscouraged, + m.intel.AdviseOnlyTrustedHomeHubs, + m.intel.Parsed().HomeHubAdvisory, + ) + // Check for UsageAsDestinationDiscouraged. + checkStatusList( + pin, + StateUsageAsDestinationDiscouraged, + m.intel.AdviseOnlyTrustedDestinationHubs, + m.intel.Parsed().DestinationHubAdvisory, + ) +} + +func checkStatusList(pin *Pin, state PinState, requireTrusted bool, endpointList endpoints.Endpoints) { + if requireTrusted && !pin.State.Has(StateTrusted) { + pin.addStates(state) + return + } + + if pin.EntityV4 != nil { + result, _ := endpointList.Match(context.TODO(), pin.EntityV4) + if result == endpoints.Denied { + pin.addStates(state) + return + } + } + + if pin.EntityV6 != nil { + result, _ := endpointList.Match(context.TODO(), pin.EntityV6) + if result == endpoints.Denied { + pin.addStates(state) + } + } +} + +func (m *Map) updateInfoOverrides(pin *Pin) { + // Check if Intel data is loaded and if there are any overrides. + if m.intel == nil { + return + } + + // Get overrides for this pin. + hubIntel, ok := m.intel.Hubs[pin.Hub.ID] + if !ok || hubIntel.Override == nil { + return + } + overrides := hubIntel.Override + + // Apply overrides + if overrides.CountryCode != "" { + if pin.LocationV4 != nil { + pin.LocationV4.Country = geoip.GetCountryInfo(overrides.CountryCode) + } + if pin.EntityV4 != nil { + pin.EntityV4.Country = overrides.CountryCode + } + if pin.LocationV6 != nil { + pin.LocationV6.Country = geoip.GetCountryInfo(overrides.CountryCode) + } + if pin.EntityV6 != nil { + pin.EntityV6.Country = overrides.CountryCode + } + } + if overrides.Coordinates != nil { + if pin.LocationV4 != nil { + pin.LocationV4.Coordinates = *overrides.Coordinates + } + if pin.EntityV4 != nil { + pin.EntityV4.Coordinates = overrides.Coordinates + } + if pin.LocationV6 != nil { + pin.LocationV6.Coordinates = *overrides.Coordinates + } + if pin.EntityV6 != nil { + pin.EntityV6.Coordinates = overrides.Coordinates + } + } + if overrides.ASN != 0 { + if pin.LocationV4 != nil { + pin.LocationV4.AutonomousSystemNumber = overrides.ASN + } + if pin.EntityV4 != nil { + pin.EntityV4.ASN = overrides.ASN + } + if pin.LocationV6 != nil { + pin.LocationV6.AutonomousSystemNumber = overrides.ASN + } + if pin.EntityV6 != nil { + pin.EntityV6.ASN = overrides.ASN + } + } + if overrides.ASOrg != "" { + if pin.LocationV4 != nil { + pin.LocationV4.AutonomousSystemOrganization = overrides.ASOrg + } + if pin.EntityV4 != nil { + pin.EntityV4.ASOrg = overrides.ASOrg + } + if pin.LocationV6 != nil { + pin.LocationV6.AutonomousSystemOrganization = overrides.ASOrg + } + if pin.EntityV6 != nil { + pin.EntityV6.ASOrg = overrides.ASOrg + } + } +} diff --git a/spn/navigator/map.go b/spn/navigator/map.go new file mode 100644 index 00000000..006dfc13 --- /dev/null +++ b/spn/navigator/map.go @@ -0,0 +1,165 @@ +package navigator + +import ( + "sort" + "sync" + "time" + + "github.com/safing/portbase/database" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" +) + +// Map represent a collection of Pins and their relationship and status. +type Map struct { + sync.RWMutex + Name string + + all map[string]*Pin + intel *hub.Intel + regions []*Region + + home *Pin + homeTerminal *docks.CraneTerminal + + measuringEnabled bool + hubUpdateHook *database.RegisteredHook + + // analysisLock guards access to all of this map's Pin.analysis, + // regardedPins and the lastDesegrationAttempt fields. + analysisLock sync.Mutex + regardedPins []*Pin + lastDesegrationAttempt time.Time +} + +// NewMap returns a new and empty Map. +func NewMap(name string, enableMeasuring bool) *Map { + m := &Map{ + Name: name, + all: make(map[string]*Pin), + measuringEnabled: enableMeasuring, + } + addMapToAPI(m) + + return m +} + +// Close removes the map's integration, taking it "offline". +func (m *Map) Close() { + removeMapFromAPI(m.Name) +} + +// GetPin returns the Pin of the Hub with the given ID. +func (m *Map) GetPin(hubID string) (pin *Pin, ok bool) { + m.RLock() + defer m.RUnlock() + + pin, ok = m.all[hubID] + return +} + +// GetHome returns the current home and it's accompanying terminal. +// Both may be nil. +func (m *Map) GetHome() (*Pin, *docks.CraneTerminal) { + m.RLock() + defer m.RUnlock() + + return m.home, m.homeTerminal +} + +// SetHome sets the given hub as the new home. Optionally, a terminal may be +// supplied to accompany the home hub. +func (m *Map) SetHome(id string, t *docks.CraneTerminal) (ok bool) { + m.Lock() + defer m.Unlock() + + // Get pin from map. + newHome, ok := m.all[id] + if !ok { + return false + } + + // Remove home hub state from all pins. + for _, pin := range m.all { + pin.removeStates(StateIsHomeHub) + } + + // Set pin as home. + m.home = newHome + m.homeTerminal = t + m.home.addStates(StateIsHomeHub) + + // Recalculate reachable. + err := m.recalculateReachableHubs() + if err != nil { + log.Warningf("spn/navigator: failed to recalculate reachable hubs: %s", err) + } + + m.PushPinChanges() + return true +} + +// GetAvailableCountries returns a map of countries including their information +// where the map has pins suitable for the given type. +func (m *Map) GetAvailableCountries(opts *Options, forType HubType) map[string]*geoip.CountryInfo { + if opts == nil { + opts = m.defaultOptions() + } + + m.RLock() + defer m.RUnlock() + + matcher := opts.Matcher(forType, m.intel) + countries := make(map[string]*geoip.CountryInfo) + for _, pin := range m.all { + if !matcher(pin) { + continue + } + if pin.LocationV4 != nil && countries[pin.LocationV4.Country.Code] == nil { + countries[pin.LocationV4.Country.Code] = &pin.LocationV4.Country + } + if pin.LocationV6 != nil && countries[pin.LocationV6.Country.Code] == nil { + countries[pin.LocationV6.Country.Code] = &pin.LocationV6.Country + } + } + + return countries +} + +// isEmpty returns whether the Map is regarded as empty. +func (m *Map) isEmpty() bool { + if m.home != nil { + // When a home hub is set, we also regard a map with only one entry to be + // empty, as this will be the case for Hubs, which will have their own + // entry in the Map. + return len(m.all) <= 1 + } + + return len(m.all) == 0 +} + +func (m *Map) pinList(lockMap bool) []*Pin { + if lockMap { + m.RLock() + defer m.RUnlock() + } + + // Copy into slice. + list := make([]*Pin, 0, len(m.all)) + for _, pin := range m.all { + list = append(list, pin) + } + + return list +} + +func (m *Map) sortedPins(lockMap bool) []*Pin { + // Get list. + list := m.pinList(lockMap) + + // Sort list. + sort.Sort(sortByPinID(list)) + return list +} diff --git a/spn/navigator/map_stats.go b/spn/navigator/map_stats.go new file mode 100644 index 00000000..c4e17108 --- /dev/null +++ b/spn/navigator/map_stats.go @@ -0,0 +1,85 @@ +package navigator + +import ( + "fmt" + "sort" + "strings" +) + +// MapStats holds generic map statistics. +type MapStats struct { + Name string + States map[PinState]int + Lanes map[int]int + ActiveTerminals int +} + +// Stats collects and returns statistics from the map. +func (m *Map) Stats() *MapStats { + m.Lock() + defer m.Unlock() + + // Create stats struct. + stats := &MapStats{ + Name: m.Name, + States: make(map[PinState]int), + Lanes: make(map[int]int), + } + for _, state := range allStates { + stats.States[state] = 0 + } + + // Iterate over all Pins to collect data. + for _, pin := range m.all { + // Count active terminals. + if pin.HasActiveTerminal() { + stats.ActiveTerminals++ + } + + // Check all states. + for _, state := range allStates { + if pin.State.Has(state) { + stats.States[state]++ + } + } + + // Count lanes. + laneCnt, ok := stats.Lanes[len(pin.ConnectedTo)] + if ok { + stats.Lanes[len(pin.ConnectedTo)] = laneCnt + 1 + } else { + stats.Lanes[len(pin.ConnectedTo)] = 1 + } + } + + return stats +} + +func (ms *MapStats) String() string { + var builder strings.Builder + + // Write header. + fmt.Fprintf(&builder, "Stats for Map %s:\n", ms.Name) + + // Write State Stats + stateSummary := make([]string, 0, len(ms.States)) + for state, cnt := range ms.States { + stateSummary = append(stateSummary, fmt.Sprintf("State %s: %d Hubs", state, cnt)) + } + sort.Strings(stateSummary) + for _, stateSum := range stateSummary { + fmt.Fprintln(&builder, stateSum) + } + + // Write Lane Stats + laneStats := make([]string, 0, len(ms.Lanes)) + for laneCnt, pinCnt := range ms.Lanes { + laneStats = append(laneStats, fmt.Sprintf("%d Lanes: %d Hubs", laneCnt, pinCnt)) + } + sort.Strings(laneStats) + for _, laneStat := range laneStats { + fmt.Fprintln(&builder, laneStat) + } + + return builder.String() +} diff --git a/spn/navigator/map_test.go b/spn/navigator/map_test.go new file mode 100644 index 00000000..bea2d477 --- /dev/null +++ b/spn/navigator/map_test.go @@ -0,0 +1,279 @@ +package navigator + +import ( + "fmt" + "net" + "sync" + "testing" + "time" + + "github.com/brianvoe/gofakeit" + + "github.com/safing/jess/lhash" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/spn/hub" +) + +var ( + fakeLock sync.Mutex + + defaultMapCreate sync.Once + defaultMap *Map +) + +func getDefaultTestMap() *Map { + defaultMapCreate.Do(func() { + defaultMap = createRandomTestMap(1, 200) + }) + return defaultMap +} + +func TestRandomMapCreation(t *testing.T) { + t.Parallel() + + m := getDefaultTestMap() + + fmt.Println("All Pins:") + for _, pin := range m.all { + fmt.Printf("%s: %s %s\n", pin, pin.Hub.Info.IPv4, pin.Hub.Info.IPv6) + } + + // Print stats + fmt.Printf("\n%s\n", m.Stats()) + + // Print home + fmt.Printf("Selected Home Hub: %s\n", m.home) +} + +func createRandomTestMap(seed int64, size int) *Map { + fakeLock.Lock() + defer fakeLock.Unlock() + + // Seed with parameter to make it reproducible. + gofakeit.Seed(seed) + + // Enforce minimum size. + if size < 10 { + size = 10 + } + + // Create Hub list. + var hubs []*hub.Hub + + // Create Intel data structure. + mapIntel := &hub.Intel{ + Hubs: make(map[string]*hub.HubIntel), + } + + // Define periodic values. + var currentGroup string + + // Create [size] fake Hubs. + for i := 0; i < size; i++ { + // Change group every 5 Hubs. + if i%5 == 0 { + currentGroup = gofakeit.Username() + } + + // Create new fake Hub and add to the list. + h := createFakeHub(currentGroup, true, mapIntel) + hubs = append(hubs, h) + } + + // Fake three superseeded Hubs. + for i := 0; i < 3; i++ { + h := hubs[size-1-i] + + // Set FirstSeen in the past and copy an IP address of an existing Hub. + h.FirstSeen = time.Now().Add(-1 * time.Hour) + if i%2 == 0 { + h.Info.IPv4 = hubs[i].Info.IPv4 + } else { + h.Info.IPv6 = hubs[i].Info.IPv6 + } + } + + // Create Lanes between Hubs in order to create the network. + totalConnections := size * 10 + for i := 0; i < totalConnections; i++ { + // Get new random indexes. + indexA := gofakeit.Number(0, size-1) + indexB := gofakeit.Number(0, size-1) + if indexA == indexB { + continue + } + + // Get Hubs and check if they are already connected. + hubA := hubs[indexA] + hubB := hubs[indexB] + if hubA.GetLaneTo(hubB.ID) != nil { + // already connected + continue + } + if hubB.GetLaneTo(hubA.ID) != nil { + // already connected + continue + } + + // Create connections. + _ = hubA.AddLane(createLane(hubB.ID)) + // Add the second connection in 99% of cases. + // If this is missing, the Pins should not show up as connected. + if gofakeit.Number(0, 100) != 0 { + _ = hubB.AddLane(createLane(hubA.ID)) + } + } + + // Parse constructed intel data + err := mapIntel.ParseAdvisories() + if err != nil { + panic(err) + } + + // Create map and add Pins. + m := NewMap(fmt.Sprintf("Test-Map-%d", seed), true) + m.intel = mapIntel + for _, h := range hubs { + m.UpdateHub(h) + } + + // Fake communication error with three Hubs. + var i int + for _, pin := range m.all { + pin.MarkAsFailingFor(1 * time.Hour) + pin.addStates(StateFailing) + + if i++; i >= 3 { + break + } + } + + // Set a Home Hub. + findFakeHomeHub(m) + + return m +} + +func createFakeHub(group string, randomFailes bool, mapIntel *hub.Intel) *hub.Hub { + // Create fake Hub ID. + idSrc := gofakeit.Password(true, true, true, true, true, 64) + id := lhash.Digest(lhash.BLAKE2b_256, []byte(idSrc)).Base58() + ip4, _ := createGoodIP(true) + ip6, _ := createGoodIP(false) + + // Create and return new fake Hub. + h := &hub.Hub{ + ID: id, + Info: &hub.Announcement{ + ID: id, + Timestamp: time.Now().Unix(), + Name: gofakeit.Username(), + Group: group, + // ContactAddress // TODO + // ContactService // TODO + // Hosters []string // TODO + // Datacenter string // TODO + IPv4: ip4, + IPv6: ip6, + }, + Status: &hub.Status{ + Timestamp: time.Now().Unix(), + Keys: map[string]*hub.Key{ + "a": { + Expires: time.Now().Add(48 * time.Hour).Unix(), + }, + }, + Load: gofakeit.Number(10, 100), + }, + Measurements: hub.NewMeasurements(), + FirstSeen: time.Now(), + } + h.Measurements.Latency = createLatency() + h.Measurements.Capacity = createCapacity() + h.Measurements.CalculatedCost = CalculateLaneCost( + h.Measurements.Latency, + h.Measurements.Capacity, + ) + + // Return if not failures of any kind should be simulated. + if !randomFailes { + return h + } + + // Set hub-based states. + if gofakeit.Number(0, 100) == 0 { + // Fake Info message error. + h.InvalidInfo = true + } + if gofakeit.Number(0, 100) == 0 { + // Fake Status message error. + h.InvalidStatus = true + } + if gofakeit.Number(0, 100) == 0 { + // Fake expired exchange keys. + for _, key := range h.Status.Keys { + key.Expires = time.Now().Add(-1 * time.Hour).Unix() + } + } + + // Return if not failures of any kind should be simulated. + if mapIntel == nil { + return h + } + + // Set advisory-based states. + if gofakeit.Number(0, 10) == 0 { + // Make Trusted State + mapIntel.Hubs[h.ID] = &hub.HubIntel{ + Trusted: true, + } + } + if gofakeit.Number(0, 100) == 0 { + // Discourage any usage. + mapIntel.HubAdvisory = append(mapIntel.HubAdvisory, "- "+h.Info.IPv4.String()) + } + if gofakeit.Number(0, 100) == 0 { + // Discourage Home Hub usage. + mapIntel.HomeHubAdvisory = append(mapIntel.HomeHubAdvisory, "- "+h.Info.IPv4.String()) + } + if gofakeit.Number(0, 100) == 0 { + // Discourage Destination Hub usage. + mapIntel.DestinationHubAdvisory = append(mapIntel.DestinationHubAdvisory, "- "+h.Info.IPv4.String()) + } + + return h +} + +func createGoodIP(v4 bool) (net.IP, *geoip.Location) { + var candidate net.IP + for i := 0; i < 100; i++ { + if v4 { + candidate = net.ParseIP(gofakeit.IPv4Address()) + } else { + candidate = net.ParseIP(gofakeit.IPv6Address()) + } + loc, err := geoip.GetLocation(candidate) + if err == nil && loc.Coordinates.Latitude != 0 { + return candidate, loc + } + } + return candidate, nil +} + +func createLane(toHubID string) *hub.Lane { + return &hub.Lane{ + ID: toHubID, + Latency: createLatency(), + Capacity: createCapacity(), + } +} + +func createLatency() time.Duration { + // Return a value between 10ms and 100ms. + return time.Duration(gofakeit.Float64Range(10, 100) * float64(time.Millisecond)) +} + +func createCapacity() int { + // Return a value between 10Mbit/s and 1Gbit/s. + return gofakeit.Number(10000000, 1000000000) +} diff --git a/spn/navigator/measurements.go b/spn/navigator/measurements.go new file mode 100644 index 00000000..571365cb --- /dev/null +++ b/spn/navigator/measurements.go @@ -0,0 +1,144 @@ +package navigator + +import ( + "context" + "sort" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/terminal" +) + +// Measurements Configuration. +const ( + NavigatorMeasurementTTLDefault = 4 * time.Hour + NavigatorMeasurementTTLByCostBase = 6 * time.Minute + NavigatorMeasurementTTLByCostMin = 4 * time.Hour + NavigatorMeasurementTTLByCostMax = 50 * time.Hour + + // With a base TTL of 3m, this leads to: + // 20c -> 2h -> raised to 4h. + // 50c -> 5h + // 100c -> 10h + // 1000c -> 100h -> capped to 50h. +) + +func (m *Map) measureHubs(ctx context.Context, _ *modules.Task) error { + if home, _ := m.GetHome(); home == nil { + log.Debug("spn/navigator: skipping measuring, no home hub set") + return nil + } + + var unknownErrCnt int + matcher := m.DefaultOptions().Transit.Matcher(m.GetIntel()) + + // Get list and sort in order to check near/low-cost hubs earlier. + list := m.pinList(true) + sort.Sort(sortByLowestMeasuredCost(list)) + + // Find first pin where any measurement has expired. + for _, pin := range list { + // Check if measuring is enabled. + if pin.measurements == nil { + continue + } + + // Check if Pin is regarded. + if !matcher(pin) { + continue + } + + // Calculate dynamic TTL. + var checkWithTTL time.Duration + if pin.HopDistance == 2 { // Hub is directly connected. + checkWithTTL = calculateMeasurementTTLByCost( + pin.measurements.GetCalculatedCost(), + docks.CraneMeasurementTTLByCostBase, + docks.CraneMeasurementTTLByCostMin, + docks.CraneMeasurementTTLByCostMax, + ) + } else { + checkWithTTL = calculateMeasurementTTLByCost( + pin.measurements.GetCalculatedCost(), + NavigatorMeasurementTTLByCostBase, + NavigatorMeasurementTTLByCostMin, + NavigatorMeasurementTTLByCostMax, + ) + } + + // Check if we have measured the pin within the TTL. + if !pin.measurements.Expired(checkWithTTL) { + continue + } + + // Measure connection. + tErr := docks.MeasureHub(ctx, pin.Hub, checkWithTTL) + + // Independent of outcome, recalculate the cost. + latency, _ := pin.measurements.GetLatency() + capacity, _ := pin.measurements.GetCapacity() + calculatedCost := CalculateLaneCost(latency, capacity) + pin.measurements.SetCalculatedCost(calculatedCost) + // Log result. + log.Infof( + "spn/navigator: updated measurements for connection to %s: %s %.2fMbit/s %.2fc", + pin.Hub, + latency, + float64(capacity)/1000000, + calculatedCost, + ) + + switch { + case tErr.IsOK(): + // All good, continue. + + case tErr.Is(terminal.ErrTryAgainLater): + if tErr.IsExternal() { + // Remote is measuring, just continue with next. + log.Debugf("spn/navigator: remote %s is measuring, continuing with next", pin.Hub) + } else { + // We are measuring, abort and restart measuring again later. + log.Debugf("spn/navigator: postponing measuring because we are currently engaged in measuring") + return nil + } + + default: + log.Warningf("spn/navigator: failed to measure connection to %s: %s", pin.Hub, tErr) + unknownErrCnt++ + if unknownErrCnt >= 3 { + log.Warningf("spn/navigator: postponing measuring task because of multiple errors") + return nil + } + } + } + + return nil +} + +// SaveMeasuredHubs saves all Hubs that have unsaved measurements. +func (m *Map) SaveMeasuredHubs() { + m.RLock() + defer m.RUnlock() + + for _, pin := range m.all { + if !pin.measurements.IsPersisted() { + if err := pin.Hub.Save(); err != nil { + log.Warningf("spn/navigator: failed to save Hub %s to persist measurements: %s", pin.Hub, err) + } + } + } +} + +func calculateMeasurementTTLByCost(cost float32, base, min, max time.Duration) time.Duration { + calculated := time.Duration(cost) * base + switch { + case calculated < min: + return min + case calculated > max: + return max + default: + return calculated + } +} diff --git a/spn/navigator/metrics.go b/spn/navigator/metrics.go new file mode 100644 index 00000000..fe62020e --- /dev/null +++ b/spn/navigator/metrics.go @@ -0,0 +1,177 @@ +package navigator + +import ( + "sort" + "sync" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/metrics" +) + +var metricsRegistered = abool.New() + +func registerMetrics() (err error) { + // Only register metrics once. + if !metricsRegistered.SetToIf(false, true) { + return nil + } + + // Map Stats. + + _, err = metrics.NewGauge( + "spn/map/main/latency/all/lowest/seconds", + nil, + getLowestLatency, + &metrics.Options{ + Name: "SPN Map Lowest Latency", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/map/main/latency/fas/lowest/seconds", + nil, + getLowestLatencyFromFas, + &metrics.Options{ + Name: "SPN Map Lowest Latency", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/map/main/capacity/all/highest/bytes", + nil, + getHighestCapacity, + &metrics.Options{ + Name: "SPN Map Lowest Latency", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/map/main/capacity/fas/highest/bytes", + nil, + getHighestCapacityFromFas, + &metrics.Options{ + Name: "SPN Map Lowest Latency", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + return nil +} + +var ( + mapStats *mapMetrics + mapStatsExpires time.Time + mapStatsLock sync.Mutex + mapStatsTTL = 55 * time.Second +) + +type mapMetrics struct { + lowestLatency float64 + lowestForeignASLatency float64 + highestCapacity float64 + highestForeignASCapacity float64 +} + +func getLowestLatency() float64 { return getMapStats().lowestLatency } +func getLowestLatencyFromFas() float64 { return getMapStats().lowestForeignASLatency } +func getHighestCapacity() float64 { return getMapStats().highestCapacity } +func getHighestCapacityFromFas() float64 { return getMapStats().highestForeignASCapacity } + +func getMapStats() *mapMetrics { + mapStatsLock.Lock() + defer mapStatsLock.Unlock() + + // Return cache if still valid. + if time.Now().Before(mapStatsExpires) { + return mapStats + } + + // Refresh. + mapStats = &mapMetrics{} + + // Get all pins and home. + list := Main.pinList(true) + home, _ := Main.GetHome() + + // Return empty stats if we have incomplete data. + if len(list) <= 1 || home == nil { + mapStatsExpires = time.Now().Add(mapStatsTTL) + return mapStats + } + + // Sort by latency. + sort.Sort(sortByLowestMeasuredLatency(list)) + // Get lowest latency. + lowestLatency, _ := list[0].measurements.GetLatency() + mapStats.lowestLatency = lowestLatency.Seconds() + // Find best foreign AS latency. + bestForeignASPin := findFirstForeignASStatsPin(home, list) + if bestForeignASPin != nil { + lowestForeignASLatency, _ := bestForeignASPin.measurements.GetLatency() + mapStats.lowestForeignASLatency = lowestForeignASLatency.Seconds() + } + + // Sort by capacity. + sort.Sort(sortByHighestMeasuredCapacity(list)) + // Get highest capacity. + highestCapacity, _ := list[0].measurements.GetCapacity() + mapStats.highestCapacity = float64(highestCapacity) / 8 + // Find best foreign AS capacity. + bestForeignASPin = findFirstForeignASStatsPin(home, list) + if bestForeignASPin != nil { + highestForeignASCapacity, _ := bestForeignASPin.measurements.GetCapacity() + mapStats.highestForeignASCapacity = float64(highestForeignASCapacity) / 8 + } + + mapStatsExpires = time.Now().Add(mapStatsTTL) + return mapStats +} + +func findFirstForeignASStatsPin(home *Pin, list []*Pin) *Pin { + // Find best foreign AS latency. + for _, pin := range list { + compared := false + + // Skip if IPv4 AS matches. + if home.LocationV4 != nil && pin.LocationV4 != nil { + if home.LocationV4.AutonomousSystemNumber == pin.LocationV4.AutonomousSystemNumber { + continue + } + compared = true + } + + // Skip if IPv6 AS matches. + if home.LocationV6 != nil && pin.LocationV6 != nil { + if home.LocationV6.AutonomousSystemNumber == pin.LocationV6.AutonomousSystemNumber { + continue + } + compared = true + } + + // Skip if no data was compared + if !compared { + continue + } + + return pin + } + return nil +} diff --git a/spn/navigator/module.go b/spn/navigator/module.go new file mode 100644 index 00000000..9937ad61 --- /dev/null +++ b/spn/navigator/module.go @@ -0,0 +1,129 @@ +package navigator + +import ( + "errors" + "time" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/spn/conf" +) + +const ( + // cfgOptionRoutingAlgorithmKey is copied from profile/config.go to avoid import loop. + cfgOptionRoutingAlgorithmKey = "spn/routingAlgorithm" + + // cfgOptionRoutingAlgorithmKey is copied from captain/config.go to avoid import loop. + cfgOptionTrustNodeNodesKey = "spn/trustNodes" +) + +var ( + // ErrHomeHubUnset is returned when the Home Hub is required and not set. + ErrHomeHubUnset = errors.New("map has no Home Hub set") + + // ErrEmptyMap is returned when the Map is empty. + ErrEmptyMap = errors.New("map is empty") + + // ErrHubNotFound is returned when the Hub was not found on the Map. + ErrHubNotFound = errors.New("hub not found") + + // ErrAllPinsDisregarded is returned when all pins have been disregarded. + ErrAllPinsDisregarded = errors.New("all pins have been disregarded") +) + +var ( + module *modules.Module + + // Main is the primary map used. + Main *Map + + devMode config.BoolOption + cfgOptionRoutingAlgorithm config.StringOption + cfgOptionTrustNodeNodes config.StringArrayOption +) + +func init() { + module = modules.Register("navigator", prep, start, stop, "terminal", "geoip", "netenv") +} + +func prep() error { + return registerAPIEndpoints() +} + +func start() error { + Main = NewMap(conf.MainMapName, true) + devMode = config.Concurrent.GetAsBool(config.CfgDevModeKey, false) + cfgOptionRoutingAlgorithm = config.Concurrent.GetAsString(cfgOptionRoutingAlgorithmKey, DefaultRoutingProfileID) + cfgOptionTrustNodeNodes = config.Concurrent.GetAsStringArray(cfgOptionTrustNodeNodesKey, []string{}) + + err := registerMapDatabase() + if err != nil { + return err + } + + // Wait for geoip databases to be ready. + // Try again if not yet ready, as this is critical. + // The "wait" parameter times out after 1 second. + // Allow 30 seconds for both databases to load. +geoInitCheck: + for i := 0; i < 30; i++ { + switch { + case !geoip.IsInitialized(false, true): // First, IPv4. + case !geoip.IsInitialized(true, true): // Then, IPv6. + default: + break geoInitCheck + } + } + + err = Main.InitializeFromDatabase() + if err != nil { + // Wait for three seconds, then try again. + time.Sleep(3 * time.Second) + err = Main.InitializeFromDatabase() + if err != nil { + // Even if the init fails, we can try to start without it and get data along the way. + log.Warningf("spn/navigator: %s", err) + } + } + err = Main.RegisterHubUpdateHook() + if err != nil { + return err + } + + // TODO: delete superseded hubs after x amount of time + + module.NewTask("update states", Main.updateStates). + Repeat(1 * time.Hour). + Schedule(time.Now().Add(3 * time.Minute)) + + module.NewTask("update failing states", Main.updateFailingStates). + Repeat(1 * time.Minute). + Schedule(time.Now().Add(3 * time.Minute)) + + if conf.PublicHub() { + // Only measure Hubs on public Hubs. + module.NewTask("measure hubs", Main.measureHubs). + Repeat(5 * time.Minute). + Schedule(time.Now().Add(1 * time.Minute)) + + // Only register metrics on Hubs, as they only make sense there. + err := registerMetrics() + if err != nil { + return err + } + } + + return nil +} + +func stop() error { + withdrawMapDatabase() + + Main.CancelHubUpdateHook() + Main.SaveMeasuredHubs() + Main.Close() + + return nil +} diff --git a/spn/navigator/module_test.go b/spn/navigator/module_test.go new file mode 100644 index 00000000..f55ea4e8 --- /dev/null +++ b/spn/navigator/module_test.go @@ -0,0 +1,13 @@ +package navigator + +import ( + "testing" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/core/pmtesting" +) + +func TestMain(m *testing.M) { + log.SetLogLevel(log.DebugLevel) + pmtesting.TestMain(m, module) +} diff --git a/spn/navigator/optimize.go b/spn/navigator/optimize.go new file mode 100644 index 00000000..76f101c3 --- /dev/null +++ b/spn/navigator/optimize.go @@ -0,0 +1,388 @@ +package navigator + +import ( + "fmt" + "sort" + "time" + + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" +) + +const ( + optimizationLowestCostConnections = 3 + optimizationHopDistanceTarget = 3 + waitUntilMeasuredUpToPercent = 0.5 + + desegrationAttemptBackoff = time.Hour +) + +// Optimization Purposes. +const ( + OptimizePurposeBootstrap = "bootstrap" + OptimizePurposeDesegregate = "desegregate" + OptimizePurposeWait = "wait" + OptimizePurposeTargetStructure = "target-structure" +) + +// AnalysisState holds state for analyzing the network for optimizations. +type AnalysisState struct { //nolint:maligned + // Suggested signifies that a direct connection to this Hub is suggested by + // the optimization algorithm. + Suggested bool + + // SuggestedHopDistance holds the hop distance to this Hub when only + // considering the suggested Hubs as connected. + SuggestedHopDistance int + + // SuggestedHopDistanceInRegion holds the hop distance to this Hub in the + // same region when only considering the suggested Hubs as connected. + SuggestedHopDistanceInRegion int + + // CrossRegionalConnections holds the amount of connections a Pin has from + // the current region. + CrossRegionalConnections int + // CrossRegionalLowestCostLane holds the lowest cost of the counted + // connections from the current region. + CrossRegionalLowestCostLane float32 + // CrossRegionalLaneCosts holds all the cross regional lane costs. + CrossRegionalLaneCosts []float32 + // CrossRegionalHighestCostInHubLimit holds to highest cost of the lowest + // cost connections within the maximum allowed lanes on a Hub from the + // current region. + CrossRegionalHighestCostInHubLimit float32 +} + +// initAnalysis creates all Pin.analysis fields. +// The caller needs to hold the map and analysis lock.. +func (m *Map) initAnalysis(result *OptimizationResult) { + // Compile lists of regarded pins. + m.regardedPins = make([]*Pin, 0, len(m.all)) + for _, region := range m.regions { + region.regardedPins = make([]*Pin, 0, len(m.all)) + } + // Find all regarded pins. + for _, pin := range m.all { + if result.matcher(pin) { + m.regardedPins = append(m.regardedPins, pin) + // Add to region. + if pin.region != nil { + pin.region.regardedPins = append(pin.region.regardedPins, pin) + } + } + } + + // Initialize analysis state. + for _, pin := range m.all { + pin.analysis = &AnalysisState{} + } +} + +// clearAnalysis reset all Pin.analysis fields. +// The caller needs to hold the map and analysis lock. +func (m *Map) clearAnalysis() { + m.regardedPins = nil + for _, region := range m.regions { + region.regardedPins = nil + } + for _, pin := range m.all { + pin.analysis = nil + } +} + +// OptimizationResult holds the result of an optimizaion analysis. +type OptimizationResult struct { + // Purpose holds a semi-human readable constant of the optimization purpose. + Purpose string + + // Approach holds human readable descriptions of how the stated purpose + // should be achieved. + Approach []string + + // SuggestedConnections holds the Hubs to which connections are suggested. + SuggestedConnections []*SuggestedConnection + + // MaxConnect specifies how many connections should be created at maximum + // based on this optimization. + MaxConnect int + + // StopOthers specifies if other connections than the suggested ones may + // be stopped. + StopOthers bool + + // opts holds the options for matching Hubs in this optimization. + opts *HubOptions + + // matcher is the matcher used to create the regarded Pins. + // Required for updating suggested hop distance. + matcher PinMatcher +} + +// SuggestedConnection holds suggestions by the optimization system. +type SuggestedConnection struct { + // Hub holds the Hub to which a connection is suggested. + Hub *hub.Hub + // pin holds the Pin of the Hub. + pin *Pin + // Reason holds a reason why this connection is suggested. + Reason string + // Duplicate marks duplicate entries. These should be ignored when + // connecting, but are helpful for understand the optimization result. + Duplicate bool +} + +func (or *OptimizationResult) addApproach(description string) { + or.Approach = append(or.Approach, description) +} + +func (or *OptimizationResult) addSuggested(reason string, pins ...*Pin) { + for _, pin := range pins { + // Mark as suggested. + pin.analysis.Suggested = true + + // Check if this is a duplicate. + var duplicate bool + for _, sc := range or.SuggestedConnections { + if pin.Hub.ID == sc.Hub.ID { + duplicate = true + break + } + } + + // Add to suggested connections. + or.SuggestedConnections = append(or.SuggestedConnections, &SuggestedConnection{ + Hub: pin.Hub, + pin: pin, + Reason: reason, + Duplicate: duplicate, + }) + + // Update hop distances if we have a matcher. + if or.matcher != nil { + or.markSuggestedReachable(pin, 2) + or.markSuggestedReachableInRegion(pin, 2) + } + } +} + +func (or *OptimizationResult) markSuggestedReachable(suggested *Pin, hopDistance int) { + // Don't update if distance is greater or equal than current one. + if hopDistance >= suggested.analysis.SuggestedHopDistance { + return + } + + // Set suggested hop distance. + suggested.analysis.SuggestedHopDistance = hopDistance + + // Increase distance and apply to matching Pins. + hopDistance++ + for _, lane := range suggested.ConnectedTo { + if or.matcher(lane.Pin) { + or.markSuggestedReachable(lane.Pin, hopDistance) + } + } +} + +// Optimize analyzes the map and suggests changes. +func (m *Map) Optimize(opts *HubOptions) (result *OptimizationResult, err error) { + m.RLock() + defer m.RUnlock() + + // Check if the map is empty. + if m.isEmpty() { + return nil, ErrEmptyMap + } + + // Set default options if unset. + if opts == nil { + opts = &HubOptions{} + } + + return m.optimize(opts) +} + +func (m *Map) optimize(opts *HubOptions) (result *OptimizationResult, err error) { + if m.home == nil { + return nil, ErrHomeHubUnset + } + + // Set default options if unset. + if opts == nil { + opts = &HubOptions{} + } + + // Create result. + result = &OptimizationResult{ + opts: opts, + matcher: opts.Matcher(TransitHub, m.intel), + } + + // Setup analyis. + m.analysisLock.Lock() + defer m.analysisLock.Unlock() + m.initAnalysis(result) + defer m.clearAnalysis() + + // Bootstrap to the network and desegregate map. + // If there is a result, return it immediately. + returnImmediately := m.optimizeForBootstrappingAndDesegregation(result) + if returnImmediately { + return result, nil + } + + // Check if we have the measurements we need. + if m.measuringEnabled { + // Cound pins with valid measurements. + var validMeasurements float32 + for _, pin := range m.regardedPins { + if pin.measurements.Valid() { + validMeasurements++ + } + } + + // If less than the required amount of regarded Pins have valid + // measurements, let's wait until we have that. + if validMeasurements/float32(len(m.regardedPins)) < waitUntilMeasuredUpToPercent { + return &OptimizationResult{ + Purpose: OptimizePurposeWait, + Approach: []string{"Wait for measurements of 80% of regarded nodes for better optimization."}, + }, nil + } + } + + // Set default values for target structure optimization. + result.Purpose = OptimizePurposeTargetStructure + result.MaxConnect = 3 + result.StopOthers = true + + // Optimize for lowest cost. + m.optimizeForLowestCost(result, optimizationLowestCostConnections) + + // Optimize for lowest cost in region. + m.optimizeForLowestCostInRegion(result) + + // Optimize for distance constraint in region. + m.optimizeForDistanceConstraintInRegion(result, 3) + + // Optimize for region-to-region connectivity. + m.optimizeForRegionConnectivity(result) + + // Optimize for satellite-to-region connectivity. + m.optimizeForSatelliteConnectivity(result) + + // Lapse traffic stats after optimizing for good fresh data next time. + for _, crane := range docks.GetAllAssignedCranes() { + crane.NetState.LapsePeriod() + } + + // Clean and return. + return result, nil +} + +func (m *Map) optimizeForBootstrappingAndDesegregation(result *OptimizationResult) (returnImmediately bool) { + // All regarded Pins are reachable. + reachable := len(m.regardedPins) + + // Count Pins that may be connectable. + connectable := make([]*Pin, 0, len(m.all)) + // Copy opts as we are going to make changes. + opts := result.opts.Copy() + opts.NoDefaults = true + opts.Regard = StateNone + opts.Disregard = StateSummaryDisregard + // Collect Pins with matcher. + matcher := opts.Matcher(TransitHub, m.intel) + for _, pin := range m.all { + if matcher(pin) { + connectable = append(connectable, pin) + } + } + + switch { + case reachable == 0: + + // Sort by lowest cost. + sort.Sort(sortByLowestMeasuredCost(connectable)) + + // Return bootstrap optimization. + result.Purpose = OptimizePurposeBootstrap + result.Approach = []string{"Connect to a near Hub to connect to the network."} + result.MaxConnect = 1 + result.addSuggested("bootstrap", connectable...) + return true + + case reachable > len(connectable)/2: + // We are part of the majority network, continue with regular optimization. + + case time.Now().Add(-desegrationAttemptBackoff).Before(m.lastDesegrationAttempt): + // We tried to desegregate recently, continue with regular optimization. + + default: + // We are in a network comprised of less than half of the known nodes. + // Attempt to connect to an unconnected one to desegregate the network. + + // Copy opts as we are going to make changes. + opts = opts.Copy() + opts.NoDefaults = true + opts.Regard = StateNone + opts.Disregard = StateSummaryDisregard | StateReachable + + // Iterate over all Pins to find any matching Pin. + desegregateWith := make([]*Pin, 0, len(m.all)-reachable) + matcher := opts.Matcher(TransitHub, m.intel) + for _, pin := range m.all { + if matcher(pin) { + desegregateWith = append(desegregateWith, pin) + } + } + + // Sort by lowest connection cost. + sort.Sort(sortByLowestMeasuredCost(desegregateWith)) + + // Build desegration optimization. + result.Purpose = OptimizePurposeDesegregate + result.Approach = []string{"Attempt to desegregate network by connection to an unreachable Hub."} + result.MaxConnect = 1 + result.addSuggested("desegregate", desegregateWith...) + + // Record desegregation attempt. + m.lastDesegrationAttempt = time.Now() + + return true + } + + return false +} + +func (m *Map) optimizeForLowestCost(result *OptimizationResult, max int) { + // Add approach. + result.addApproach(fmt.Sprintf("Connect to best (lowest cost) %d Hubs globally.", max)) + + // Sort by lowest cost. + sort.Sort(sortByLowestMeasuredCost(m.regardedPins)) + + // Add to suggested pins. + if len(m.regardedPins) <= max { + result.addSuggested("best globally", m.regardedPins...) + } else { + result.addSuggested("best globally", m.regardedPins[:max]...) + } +} + +func (m *Map) optimizeForDistanceConstraint(result *OptimizationResult, max int) { //nolint:unused // TODO: Likely to be used again. + // Add approach. + result.addApproach(fmt.Sprintf("Satisfy max hop constraint of %d globally.", optimizationHopDistanceTarget)) + + for i := 0; i < max; i++ { + // Sort by lowest cost. + sort.Sort(sortBySuggestedHopDistanceAndLowestMeasuredCost(m.regardedPins)) + + // Return when all regarded Pins are within the distance constraint. + if m.regardedPins[0].analysis.SuggestedHopDistance <= optimizationHopDistanceTarget { + return + } + + // If not, suggest a connection to the best match. + result.addSuggested("satisfy global hop constraint", m.regardedPins[0]) + } +} diff --git a/spn/navigator/optimize_region.go b/spn/navigator/optimize_region.go new file mode 100644 index 00000000..5f362e18 --- /dev/null +++ b/spn/navigator/optimize_region.go @@ -0,0 +1,224 @@ +package navigator + +import ( + "fmt" + "sort" +) + +func (or *OptimizationResult) markSuggestedReachableInRegion(suggested *Pin, hopDistance int) { + // Abort if suggested Pin has no region. + if suggested.region == nil { + return + } + + // Don't update if distance is greater or equal than current one. + if hopDistance >= suggested.analysis.SuggestedHopDistanceInRegion { + return + } + + // Set suggested hop distance. + suggested.analysis.SuggestedHopDistanceInRegion = hopDistance + + // Increase distance and apply to matching Pins. + hopDistance++ + for _, lane := range suggested.ConnectedTo { + if lane.Pin.region != nil && + lane.Pin.region.ID == suggested.region.ID && + or.matcher(lane.Pin) { + or.markSuggestedReachableInRegion(lane.Pin, hopDistance) + } + } +} + +func (m *Map) optimizeForLowestCostInRegion(result *OptimizationResult) { + if m.home == nil || m.home.region == nil { + return + } + region := m.home.region + + // Add approach. + result.addApproach(fmt.Sprintf("Connect to best (lowest cost) %d Hubs within the region.", region.internalMinLanesOnHub)) + + // Sort by lowest cost. + sort.Sort(sortByLowestMeasuredCost(region.regardedPins)) + + // Add to suggested pins. + if len(region.regardedPins) <= region.internalMinLanesOnHub { + result.addSuggested("best in region", region.regardedPins...) + } else { + result.addSuggested("best in region", region.regardedPins[:region.internalMinLanesOnHub]...) + } +} + +func (m *Map) optimizeForDistanceConstraintInRegion(result *OptimizationResult, max int) { + if m.home == nil || m.home.region == nil { + return + } + region := m.home.region + + // Add approach. + result.addApproach(fmt.Sprintf("Satisfy max hop constraint of %d within the region.", region.internalMaxHops)) + + // Sort by lowest cost. + sort.Sort(sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost(region.regardedPins)) + + for i := 0; i < max && i < len(region.regardedPins); i++ { + // Return when all regarded Pins are within the distance constraint. + if region.regardedPins[i].analysis.SuggestedHopDistanceInRegion <= region.internalMaxHops { + return + } + + // If not, suggest a connection to the best match. + result.addSuggested("satisfy regional hop constraint", region.regardedPins[i]) + } +} + +func (m *Map) optimizeForRegionConnectivity(result *OptimizationResult) { + if m.home == nil || m.home.region == nil { + return + } + region := m.home.region + + // Add approach. + result.addApproach("Connect region to other regions.") + + // Optimize for every region. +checkRegions: + for _, otherRegion := range m.regions { + // Skip own region. + if region.ID == otherRegion.ID { + continue + } + + // Collect data on connections to that region. + lanesToRegion, highestCostWithinLaneLimit := m.countConnectionsToRegion(result, region, otherRegion) + + // Sort by lowest cost. + sort.Sort(sortByLowestMeasuredCost(otherRegion.regardedPins)) + + // Find cheapest connections with a free slot or better values. + var lanesSuggested int + for _, pin := range otherRegion.regardedPins { + myCost := pin.measurements.GetCalculatedCost() + + // Check if we are done or region is satisfied. + switch { + case lanesSuggested >= region.regionalMaxLanesOnHub: + // We hit our max. + continue checkRegions + case lanesToRegion >= otherRegion.regionalMinLanes && myCost >= highestCostWithinLaneLimit: + // Region has enough lanes and we are not better. + continue checkRegions + } + + // Check if we can contribute on this Pin. + switch { + case pin.analysis.CrossRegionalConnections < otherRegion.regionalMaxLanesOnHub && + lanesToRegion < otherRegion.regionalMinLanes: + // There is a free spot on this Pin and the region needs more connections. + result.addSuggested("occupy cross-region lane on pin", pin) + lanesSuggested++ + lanesToRegion++ + // Because our own Pin is not counted, this should be the default + // suggestion for a stable network. + + case myCost < pin.analysis.CrossRegionalHighestCostInHubLimit: + // We have a better connection to this Pin than at least one other existing connection (within the limit!). + result.addSuggested("replace cross-region lane on pin", pin) + lanesSuggested++ + lanesToRegion++ + + case myCost < highestCostWithinLaneLimit && + pin.analysis.CrossRegionalConnections < otherRegion.regionalMaxLanesOnHub: + // We have a better connection to this Pin than another existing region-to-region connection. + result.addSuggested("replace unrelated cross-region lane", pin) + lanesSuggested++ + lanesToRegion++ + } + } + } +} + +// countConnectionsToRegion analyzes existing lanes from this to another +// region, with taking lanes from this Hub into account. +func (m *Map) countConnectionsToRegion(result *OptimizationResult, region *Region, otherRegion *Region) (lanesToRegion int, highestCostWithinLaneLimit float32) { + for _, pin := range region.regardedPins { + // Skip self. + if m.home.Hub.ID == pin.Hub.ID { + continue + } + + // Find lanes to other region. + for _, lane := range pin.ConnectedTo { + if lane.Pin.region != nil && + lane.Pin.region.ID == otherRegion.ID && + result.matcher(lane.Pin) { + // This is a lane from this region to a regarded Pin in the other region. + lanesToRegion++ + + // Count cross region connection. + lane.Pin.analysis.CrossRegionalConnections++ + + // Collect lane costs. + lane.Pin.analysis.CrossRegionalLaneCosts = append( + lane.Pin.analysis.CrossRegionalLaneCosts, + lane.Cost, + ) + } + } + } + + // Calculate lane costs from collected lane costs. + for _, pin := range otherRegion.regardedPins { + sort.Sort(sortCostsByLowest(pin.analysis.CrossRegionalLaneCosts)) + switch { + case len(pin.analysis.CrossRegionalLaneCosts) == 0: + // Nothing to do. + case len(pin.analysis.CrossRegionalLaneCosts) < otherRegion.regionalMaxLanesOnHub: + pin.analysis.CrossRegionalLowestCostLane = pin.analysis.CrossRegionalLaneCosts[0] + pin.analysis.CrossRegionalHighestCostInHubLimit = pin.analysis.CrossRegionalLaneCosts[len(pin.analysis.CrossRegionalLaneCosts)-1] + default: + pin.analysis.CrossRegionalLowestCostLane = pin.analysis.CrossRegionalLaneCosts[0] + pin.analysis.CrossRegionalHighestCostInHubLimit = pin.analysis.CrossRegionalLaneCosts[otherRegion.regionalMaxLanesOnHub-1] + } + + // Find highest cost within limit. + if pin.analysis.CrossRegionalHighestCostInHubLimit > highestCostWithinLaneLimit { + highestCostWithinLaneLimit = pin.analysis.CrossRegionalHighestCostInHubLimit + } + } + + return lanesToRegion, highestCostWithinLaneLimit +} + +func (m *Map) optimizeForSatelliteConnectivity(result *OptimizationResult) { + if m.home == nil { + return + } + // This is only for Hubs that are not in a region. + if m.home.region != nil { + return + } + + // Add approach. + result.addApproach("Connect satellite to regions.") + + // Optimize for every region. + for _, region := range m.regions { + // Sort by lowest cost. + sort.Sort(sortByLowestMeasuredCost(region.regardedPins)) + + // Add to suggested pins. + if len(region.regardedPins) <= region.satelliteMinLanes { + result.addSuggested("best to region "+region.ID, region.regardedPins...) + } else { + result.addSuggested("best to region "+region.ID, region.regardedPins[:region.satelliteMinLanes]...) + } + } +} + +type sortCostsByLowest []float32 + +func (a sortCostsByLowest) Len() int { return len(a) } +func (a sortCostsByLowest) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortCostsByLowest) Less(i, j int) bool { return a[i] < a[j] } diff --git a/spn/navigator/optimize_test.go b/spn/navigator/optimize_test.go new file mode 100644 index 00000000..83f778cf --- /dev/null +++ b/spn/navigator/optimize_test.go @@ -0,0 +1,188 @@ +package navigator + +import ( + "strings" + "sync" + "testing" + + "github.com/safing/portmaster/spn/hub" +) + +var ( + optimizedDefaultMapCreate sync.Once + optimizedDefaultMap *Map +) + +func getOptimizedDefaultTestMap(t *testing.T) *Map { + t.Helper() + + optimizedDefaultMapCreate.Do(func() { + optimizedDefaultMap = createRandomTestMap(2, 100) + optimizedDefaultMap.optimizeTestMap(t) + }) + return optimizedDefaultMap +} + +func (m *Map) optimizeTestMap(t *testing.T) { + t.Helper() + t.Logf("optimizing test map %s with %d pins", m.Name, len(m.all)) + + // Save original Home, as we will be switching around the home for the + // optimization. + run := 0 + newLanes := 0 + originalHome := m.home + mcf := newMeasurementCachedFactory() + + for { + run++ + newLanesInRun := 0 + // Let's check if we have a run without any map changes. + lastRun := true + + for _, pin := range m.all { + // Set Home to this Pin for this iteration. + if !m.SetHome(pin.Hub.ID, nil) { + panic("failed to set home") + } + + // Update measurements for the new home. + updateMeasurements(m, mcf) + + optimizeResult, err := m.optimize(nil) + if err != nil { + panic(err) + } + lanesCreatedWithResult := 0 + for _, connectTo := range optimizeResult.SuggestedConnections { + // Check if lane to suggested Hub already exists. + if m.home.Hub.GetLaneTo(connectTo.Hub.ID) != nil { + continue + } + + // Add lanes to the Hub status. + _ = m.home.Hub.AddLane(createLane(connectTo.Hub.ID)) + _ = connectTo.Hub.AddLane(createLane(m.home.Hub.ID)) + + // Update Hubs in map. + m.UpdateHub(m.home.Hub) + m.UpdateHub(connectTo.Hub) + newLanes++ + newLanesInRun++ + + // We are changing the map in this run, so this is not the last. + lastRun = false + + // Only create as many lanes as suggested by the result. + lanesCreatedWithResult++ + if lanesCreatedWithResult >= optimizeResult.MaxConnect { + break + } + } + if optimizeResult.Purpose != OptimizePurposeTargetStructure { + // If we aren't yet building the target structure, we need to keep building. + lastRun = false + } + } + + // Log progress. + if t != nil { + t.Logf( + "optimizing: added %d lanes in run #%d (%d Hubs) - %d new lanes in total", + newLanesInRun, + run, + len(m.all), + newLanes, + ) + } + + // End optimization after last run. + if lastRun { + break + } + } + + // Log what was done and set home back to the original value. + if t != nil { + t.Logf("finished optimizing test map %s: added %d lanes in %d runs", m.Name, newLanes, run) + } + m.home = originalHome +} + +func TestOptimize(t *testing.T) { + t.Parallel() + + m := getOptimizedDefaultTestMap(t) + matcher := m.defaultOptions().Destination.Matcher(m.intel) + originalHome := m.home + + for _, pin := range m.all { + // Set Home to this Pin for this iteration. + m.home = pin + err := m.recalculateReachableHubs() + if err != nil { + panic(err) + } + + for _, peer := range m.all { + // Check if the Pin matches the criteria. + if !matcher(peer) { + continue + } + + // TODO: Adapt test to new regions. + if peer.HopDistance > 5 { + t.Errorf("Optimization error: %s is %d hops away from %s", peer, peer.HopDistance, pin) + } + } + } + + // Print stats + t.Logf("optimized map:\n%s\n", m.Stats()) + + m.home = originalHome +} + +func updateMeasurements(m *Map, mcf *measurementCachedFactory) { + for _, pin := range m.all { + pin.measurements = mcf.getOrCreate(m.home.Hub.ID, pin.Hub.ID) + } +} + +type measurementCachedFactory struct { + cache map[string]*hub.Measurements +} + +func newMeasurementCachedFactory() *measurementCachedFactory { + return &measurementCachedFactory{ + cache: make(map[string]*hub.Measurements), + } +} + +func (mcf *measurementCachedFactory) getOrCreate(from, to string) *hub.Measurements { + var id string + comparison := strings.Compare(from, to) + switch { + case comparison == 0: + return nil + case comparison > 0: + id = from + "-" + to + case comparison < 0: + id = to + "-" + from + } + + m, ok := mcf.cache[id] + if ok { + return m + } + + m = hub.NewMeasurements() + m.Latency = createLatency() + m.Capacity = createCapacity() + m.CalculatedCost = CalculateLaneCost( + m.Latency, + m.Capacity, + ) + mcf.cache[id] = m + return m +} diff --git a/spn/navigator/options.go b/spn/navigator/options.go new file mode 100644 index 00000000..05c93ea1 --- /dev/null +++ b/spn/navigator/options.go @@ -0,0 +1,330 @@ +package navigator + +import ( + "context" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/hub" +) + +// HubType is the usage type of a Hub in routing. +type HubType uint8 + +// Hub Types. +const ( + HomeHub HubType = iota + TransitHub + DestinationHub +) + +// DeriveTunnelOptions derives and returns the tunnel options from the connection and profile. +// This function lives in firewall/tunnel.go and is set here to avoid import loops. +var DeriveTunnelOptions func(lp *profile.LayeredProfile, destination *intel.Entity, connEncrypted bool) *Options + +// Options holds configuration options for operations with the Map. +type Options struct { //nolint:maligned + // Home holds the options for Home Hubs. + Home *HomeHubOptions + + // Transit holds the options for Transit Hubs. + Transit *TransitHubOptions + + // Destination holds the options for Destination Hubs. + Destination *DestinationHubOptions + + // RoutingProfile defines the algorithm to use to find a route. + RoutingProfile string +} + +// HomeHubOptions holds configuration options for Home Hub operations with the Map. +type HomeHubOptions HubOptions + +// TransitHubOptions holds configuration options for Transit Hub operations with the Map. +type TransitHubOptions HubOptions + +// DestinationHubOptions holds configuration options for Destination Hub operations with the Map. +type DestinationHubOptions HubOptions + +// HubOptions holds configuration options for a specific hub type for operations with the Map. +type HubOptions struct { + // Regard holds required States. Only Hubs where all of these are present + // will taken into account for the operation. If NoDefaults is not set, a + // basic set of desirable states is added automatically. + Regard PinState + + // Disregard holds disqualifying States. Only Hubs where none of these are + // present will be taken into account for the operation. If NoDefaults is not + // set, a basic set of undesirable states is added automatically. + Disregard PinState + + // NoDefaults declares whether default and recommended Regard and Disregard states should not be used. + NoDefaults bool + + // HubPolicies is a collection of endpoint lists that Hubs must pass in order + // to be taken into account for the operation. + HubPolicies []endpoints.Endpoints + + // RequireVerifiedOwners specifies which verified owners are allowed to be used. + // If the list is empty, all owners are allowed. + RequireVerifiedOwners []string + + // CheckHubPolicyWith provides an entity that must match the Hubs entry or exit + // policy (depending on type) in order to be taken into account for the operation. + CheckHubPolicyWith *intel.Entity +} + +// Copy returns a shallow copy of the Options. +func (o *Options) Copy() *Options { + copied := &Options{ + RoutingProfile: o.RoutingProfile, + } + if o.Home != nil { + c := HomeHubOptions(HubOptions(*o.Home).Copy()) + copied.Home = &c + } + if o.Transit != nil { + c := TransitHubOptions(HubOptions(*o.Transit).Copy()) + copied.Transit = &c + } + if o.Destination != nil { + c := DestinationHubOptions(HubOptions(*o.Destination).Copy()) + copied.Destination = &c + } + return copied +} + +// Copy returns a shallow copy of the Options. +func (o HubOptions) Copy() HubOptions { + return HubOptions{ + Regard: o.Regard, + Disregard: o.Disregard, + NoDefaults: o.NoDefaults, + HubPolicies: o.HubPolicies, + RequireVerifiedOwners: o.RequireVerifiedOwners, + CheckHubPolicyWith: o.CheckHubPolicyWith, + } +} + +// PinMatcher is a stateful matching function generated by Options. +type PinMatcher func(pin *Pin) bool + +// DefaultOptions returns the default options for this Map. +func (m *Map) DefaultOptions() *Options { + m.Lock() + defer m.Unlock() + + return m.defaultOptions() +} + +func (m *Map) defaultOptions() *Options { + opts := &Options{ + RoutingProfile: DefaultRoutingProfileID, + } + + return opts +} + +// HubPoliciesAreSet returns whether any of the given hub policies are set and non-empty. +func HubPoliciesAreSet(policies []endpoints.Endpoints) bool { + for _, policy := range policies { + if policy.IsSet() { + return true + } + } + return false +} + +var emptyHubOptions = &HubOptions{} + +// Matcher generates a PinMatcher based on the Options. +func (o *HomeHubOptions) Matcher(hubIntel *hub.Intel) PinMatcher { + if o == nil { + return emptyHubOptions.Matcher(HomeHub, hubIntel) + } + + // Convert and call base func. + ho := HubOptions(*o) + return ho.Matcher(HomeHub, hubIntel) +} + +// Matcher generates a PinMatcher based on the Options. +func (o *TransitHubOptions) Matcher(hubIntel *hub.Intel) PinMatcher { + if o == nil { + return emptyHubOptions.Matcher(TransitHub, hubIntel) + } + + // Convert and call base func. + ho := HubOptions(*o) + return ho.Matcher(TransitHub, hubIntel) +} + +// Matcher generates a PinMatcher based on the Options. +func (o *DestinationHubOptions) Matcher(hubIntel *hub.Intel) PinMatcher { + if o == nil { + return emptyHubOptions.Matcher(DestinationHub, hubIntel) + } + + // Convert and call base func. + ho := HubOptions(*o) + return ho.Matcher(DestinationHub, hubIntel) +} + +// Matcher generates a PinMatcher based on the Options. +// Always use the Matcher on option structs if you can. +func (o *Options) Matcher(hubType HubType, hubIntel *hub.Intel) PinMatcher { + switch hubType { + case HomeHub: + return o.Home.Matcher(hubIntel) + case TransitHub: + return o.Transit.Matcher(hubIntel) + case DestinationHub: + return o.Destination.Matcher(hubIntel) + default: + return nil // This will panic, but should never be used. + } +} + +// Matcher generates a PinMatcher based on the Options. +func (o *HubOptions) Matcher(hubType HubType, hubIntel *hub.Intel) PinMatcher { + // Fallback to empty hub options. + if o == nil { + o = emptyHubOptions + } + + // Compile states to regard and disregard. + regard := o.Regard + disregard := o.Disregard + + // Add default states. + if !o.NoDefaults { + // Add default States. + regard = regard.Add(StateSummaryRegard) + disregard = disregard.Add(StateSummaryDisregard) + + // Add type based Advisories. + switch hubType { + case HomeHub: + // Home Hubs don't need to be reachable and don't need keys ready to be used. + regard = regard.Remove(StateReachable) + regard = regard.Remove(StateActive) + // Follow advisory. + disregard = disregard.Add(StateUsageAsHomeDiscouraged) + // Home Hub may be the current Home Hub. + disregard = disregard.Remove(StateIsHomeHub) + case TransitHub: + // Transit Hubs get no additional states. + case DestinationHub: + // Follow advisory. + disregard = disregard.Add(StateUsageAsDestinationDiscouraged) + // Do not use if Hub reports network issues. + disregard = disregard.Add(StateConnectivityIssues) + } + } + + // Add intel policies. + hubPolicies := o.HubPolicies + if hubIntel != nil && hubIntel.Parsed() != nil { + switch hubType { + case HomeHub: + hubPolicies = append(hubPolicies, hubIntel.Parsed().HubAdvisory, hubIntel.Parsed().HomeHubAdvisory) + case TransitHub: + hubPolicies = append(hubPolicies, hubIntel.Parsed().HubAdvisory) + case DestinationHub: + hubPolicies = append(hubPolicies, hubIntel.Parsed().HubAdvisory, hubIntel.Parsed().DestinationHubAdvisory) + } + } + + // Add entry/exit policiy checks. + checkHubPolicyWith := o.CheckHubPolicyWith + + return func(pin *Pin) bool { + // Check required Pin States. + if !pin.State.Has(regard) || pin.State.HasAnyOf(disregard) { + return false + } + + // Check verified owners. + if len(o.RequireVerifiedOwners) > 0 { + // Check if Pin has a verified owner at all. + if pin.VerifiedOwner == "" { + return false + } + + // Check if verified owner is in the list. + inList := false + for _, allowed := range o.RequireVerifiedOwners { + if pin.VerifiedOwner == allowed { + inList = true + break + } + } + + // Pin does not have a verified owner from the allowed list. + if !inList { + return false + } + } + + // Check policies. + policyCheck: + for _, policy := range hubPolicies { + // Check if policy is set. + if !policy.IsSet() { + continue + } + + // Check if policy matches. + result, reason := policy.MatchMulti(context.TODO(), pin.EntityV4, pin.EntityV6) + switch result { + case endpoints.NoMatch: + // Continue with check. + case endpoints.MatchError: + log.Warningf("spn/navigator: failed to match policy: %s", reason) + // Continue with check for now. + // TODO: Rethink how to do this. If eg. the geoip database has a + // problem, then no Hub will match. For now, just continue to the + // next rule set. Not optimal, but fail safe. + case endpoints.Denied: + // Explicitly denied, abort immediately. + return false + case endpoints.Permitted: + // Explicitly allowed, abort check and continue. + break policyCheck + } + } + + // Check entry/exit policies. + if checkHubPolicyWith != nil { + switch hubType { + case HomeHub: + if endpointListMatch(pin.Hub.Info.EntryPolicy(), checkHubPolicyWith) == endpoints.Denied { + // Hub does not allow entry from the given entity. + return false + } + case TransitHub: + // Transit Hubs do not have a hub policy. + case DestinationHub: + if endpointListMatch(pin.Hub.Info.ExitPolicy(), checkHubPolicyWith) == endpoints.Denied { + // Hub does not allow exit to the given entity. + return false + } + } + } + + return true // All checks have passed. + } +} + +func endpointListMatch(list endpoints.Endpoints, entity *intel.Entity) endpoints.EPResult { + // Check if endpoint list and entity are available. + if !list.IsSet() || entity == nil { + return endpoints.NoMatch + } + + // Match and return result only. + result, _ := list.Match(context.TODO(), entity) + return result +} diff --git a/spn/navigator/pin.go b/spn/navigator/pin.go new file mode 100644 index 00000000..9e113ab4 --- /dev/null +++ b/spn/navigator/pin.go @@ -0,0 +1,269 @@ +package navigator + +import ( + "context" + "net" + "strings" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/spn/docks" + "github.com/safing/portmaster/spn/hub" +) + +// Pin represents a Hub on a Map. +type Pin struct { //nolint:maligned + // Hub Information + Hub *hub.Hub + EntityV4 *intel.Entity + EntityV6 *intel.Entity + LocationV4 *geoip.Location + LocationV6 *geoip.Location + + // Hub Status + State PinState + // VerifiedOwner holds the name of the verified owner / operator of the Hub. + VerifiedOwner string + // HopDistance signifies the needed hops to reach this Hub. + // HopDistance is measured from the view of a client. + // A Hub itself will have itself at distance 1. + // Directly connected Hubs have a distance of 2. + HopDistance int + // Cost is the routing cost of this Hub. + Cost float32 + // ConnectedTo holds validated lanes. + ConnectedTo map[string]*Lane // Key is Hub ID. + + // FailingUntil specifies until when this Hub should be regarded as failing. + // This is connected to StateFailing. + FailingUntil time.Time + + // Connection holds a information about a connection to the Hub of this Pin. + Connection *PinConnection + + // Internal + + // pushChanges is set to true if something noteworthy on the Pin changed and + // an update needs to be pushed by the database storage interface to whoever + // is listening. + pushChanges *abool.AtomicBool + + // measurements holds Measurements regarding this Pin. + // It must always be set and the reference must not be changed when measuring + // is enabled. + // Access to fields within are coordinated by itself. + measurements *hub.Measurements + + // analysis holds the analysis state. + // Should only be set during analysis and be reset at the start and removed at the end of an analysis. + analysis *AnalysisState + + // region is the region this Pin belongs to. + region *Region +} + +// PinConnection represents a connection to a terminal on the Hub. +type PinConnection struct { + // Terminal holds the active terminal session. + Terminal *docks.ExpansionTerminal + + // Route is the route built for this terminal. + Route *Route +} + +// Lane is a connection to another Hub. +type Lane struct { + // Pin is the Pin/Hub this Lane connects to. + Pin *Pin + + // Capacity designates the available bandwidth between these Hubs. + // It is specified in bit/s. + Capacity int + + // Lateny designates the latency between these Hubs. + // It is specified in nanoseconds. + Latency time.Duration + + // Cost is the routing cost of this lane. + Cost float32 + + // active is a helper flag in order help remove abandoned Lanes. + active bool +} + +// Lock locks the Pin via the Hub's lock. +func (pin *Pin) Lock() { + pin.Hub.Lock() +} + +// Unlock unlocks the Pin via the Hub's lock. +func (pin *Pin) Unlock() { + pin.Hub.Unlock() +} + +// String returns a human-readable representation of the Pin. +func (pin *Pin) String() string { + return "" +} + +// GetState returns the state of the pin. +func (pin *Pin) GetState() PinState { + pin.Lock() + defer pin.Unlock() + + return pin.State +} + +// updateLocationData fetches the necessary location data in order to correctly map out the Pin. +func (pin *Pin) updateLocationData() { + // TODO: We are currently assigning the Hub ID to the entity domain to + // support matching a Hub by its ID. The issue here is that the domain + // rules are lower-cased, so we have to lower-case the ID here too. + // This is not optimal from a security perspective, but there are still + // enough bits left that this cannot be easily exploited. + + if pin.Hub.Info.IPv4 != nil { + pin.EntityV4 = (&intel.Entity{ + IP: pin.Hub.Info.IPv4, + Domain: strings.ToLower(pin.Hub.ID) + ".", + }).Init(0) + + var ok bool + pin.LocationV4, ok = pin.EntityV4.GetLocation(context.TODO()) + if !ok { + log.Warningf("spn/navigator: failed to get location of %s of %s", pin.Hub.Info.IPv4, pin.Hub.StringWithoutLocking()) + return + } + } else { + pin.EntityV4 = nil + pin.LocationV4 = nil + } + + if pin.Hub.Info.IPv6 != nil { + pin.EntityV6 = (&intel.Entity{ + IP: pin.Hub.Info.IPv6, + Domain: strings.ToLower(pin.Hub.ID) + ".", + }).Init(0) + + var ok bool + pin.LocationV6, ok = pin.EntityV6.GetLocation(context.TODO()) + if !ok { + log.Warningf("spn/navigator: failed to get location of %s of %s", pin.Hub.Info.IPv6, pin.Hub.StringWithoutLocking()) + return + } + } else { + pin.EntityV6 = nil + pin.LocationV6 = nil + } +} + +// GetLocation returns the geoip location of the Pin, preferring first the given IP, then IPv4. +func (pin *Pin) GetLocation(ip net.IP) *geoip.Location { + pin.Lock() + defer pin.Unlock() + + switch { + case ip != nil && ip.Equal(pin.Hub.Info.IPv4) && pin.LocationV4 != nil: + return pin.LocationV4 + case ip != nil && ip.Equal(pin.Hub.Info.IPv6) && pin.LocationV6 != nil: + return pin.LocationV6 + case pin.LocationV4 != nil: + return pin.LocationV4 + case pin.LocationV6 != nil: + return pin.LocationV6 + default: + return nil + } +} + +// SetActiveTerminal sets an active terminal for the pin. +func (pin *Pin) SetActiveTerminal(pc *PinConnection) { + pin.Lock() + defer pin.Unlock() + + pin.Connection = pc + if pin.Connection != nil && pin.Connection.Terminal != nil { + pin.Connection.Terminal.SetChangeNotifyFunc(pin.NotifyTerminalChange) + } + + pin.pushChanges.Set() +} + +// GetActiveTerminal returns the active terminal of the pin. +func (pin *Pin) GetActiveTerminal() *docks.ExpansionTerminal { + pin.Lock() + defer pin.Unlock() + + if !pin.hasActiveTerminal() { + return nil + } + return pin.Connection.Terminal +} + +// HasActiveTerminal returns whether the Pin has an active terminal. +func (pin *Pin) HasActiveTerminal() bool { + pin.Lock() + defer pin.Unlock() + + return pin.hasActiveTerminal() +} + +func (pin *Pin) hasActiveTerminal() bool { + return pin.Connection != nil && + pin.Connection.Terminal.Abandoning.IsNotSet() +} + +// NotifyTerminalChange notifies subscribers of the changed terminal. +func (pin *Pin) NotifyTerminalChange() { + pin.pushChanges.Set() + pin.pushChange() +} + +// IsFailing returns whether the pin should be treated as failing. +// The Pin is locked for this. +func (pin *Pin) IsFailing() bool { + pin.Lock() + defer pin.Unlock() + + return time.Now().Before(pin.FailingUntil) +} + +// MarkAsFailingFor marks the pin as failing. +// The Pin is locked for this. +// Changes are pushed. +func (pin *Pin) MarkAsFailingFor(duration time.Duration) { + pin.Lock() + defer pin.Unlock() + + until := time.Now().Add(duration) + // Only ever increase failing until, never reduce. + if until.After(pin.FailingUntil) { + pin.FailingUntil = until + } + + pin.addStates(StateFailing) + + pin.pushChanges.Set() + pin.pushChange() +} + +// ResetFailingState resets the failing state. +// The Pin is locked for this. +// Changes are not pushed, but Pins are marked. +func (pin *Pin) ResetFailingState() { + pin.Lock() + defer pin.Unlock() + + if time.Now().Before(pin.FailingUntil) { + pin.FailingUntil = time.Now() + pin.pushChanges.Set() + } + if pin.State.Has(StateFailing) { + pin.removeStates(StateFailing) + pin.pushChanges.Set() + } +} diff --git a/spn/navigator/pin_export.go b/spn/navigator/pin_export.go new file mode 100644 index 00000000..85fd279e --- /dev/null +++ b/spn/navigator/pin_export.go @@ -0,0 +1,98 @@ +package navigator + +import ( + "sync" + "time" + + "github.com/safing/portbase/database/record" + "github.com/safing/portmaster/service/intel" + "github.com/safing/portmaster/spn/hub" +) + +// PinExport is the exportable version of a Pin. +type PinExport struct { + record.Base + sync.Mutex + + ID string + Name string + Map string + FirstSeen time.Time + + EntityV4 *intel.Entity + EntityV6 *intel.Entity + // TODO: add coords + + States []string // From pin.State + VerifiedOwner string + HopDistance int + + ConnectedTo map[string]*LaneExport // Key is Hub ID. + Route []string // Includes Home Hub and this Pin's ID. + SessionActive bool + + Info *hub.Announcement + Status *hub.Status +} + +// LaneExport is the exportable version of a Lane. +type LaneExport struct { + HubID string + + // Capacity designates the available bandwidth between these Hubs. + // It is specified in bit/s. + Capacity int + + // Lateny designates the latency between these Hubs. + // It is specified in nanoseconds. + Latency time.Duration +} + +// Export puts the Pin's information into an exportable format. +func (pin *Pin) Export() *PinExport { + pin.Lock() + defer pin.Unlock() + + // Shallow copy static values. + export := &PinExport{ + ID: pin.Hub.ID, + Name: pin.Hub.Info.Name, + Map: pin.Hub.Map, + FirstSeen: pin.Hub.FirstSeen, + EntityV4: pin.EntityV4, + EntityV6: pin.EntityV6, + States: pin.State.Export(), + VerifiedOwner: pin.VerifiedOwner, + HopDistance: pin.HopDistance, + SessionActive: pin.hasActiveTerminal() || pin.State.Has(StateIsHomeHub), + Info: pin.Hub.Info, // Is updated as a whole, no need to copy. + Status: pin.Hub.Status, // Is updated as a whole, no need to copy. + } + + // Export lanes. + export.ConnectedTo = make(map[string]*LaneExport, len(pin.ConnectedTo)) + for key, lane := range pin.ConnectedTo { + export.ConnectedTo[key] = &LaneExport{ + HubID: lane.Pin.Hub.ID, + Capacity: lane.Capacity, + Latency: lane.Latency, + } + } + + // Export route to Pin, if connected. + if pin.Connection != nil && pin.Connection.Route != nil { + export.Route = make([]string, len(pin.Connection.Route.Path)) + for key, hop := range pin.Connection.Route.Path { + export.Route[key] = hop.HubID + } + } + + // Create database record metadata. + export.SetKey(makeDBKey(export.Map, export.ID)) + export.SetMeta(&record.Meta{ + Created: export.FirstSeen.Unix(), + Modified: time.Now().Unix(), + }) + + return export +} diff --git a/spn/navigator/region.go b/spn/navigator/region.go new file mode 100644 index 00000000..a3798efe --- /dev/null +++ b/spn/navigator/region.go @@ -0,0 +1,231 @@ +package navigator + +import ( + "context" + "math" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/profile/endpoints" + "github.com/safing/portmaster/spn/hub" +) + +const ( + defaultRegionalMinLanesPerHub = 0.5 + defaultRegionalMaxLanesOnHub = 2 + defaultSatelliteMinLanesPerHub = 0.3 + defaultInternalMinLanesOnHub = 3 + defaultInternalMaxHops = 3 +) + +// Region specifies a group of Hubs for optimization purposes. +type Region struct { + ID string + Name string + config *hub.RegionConfig + memberPolicy endpoints.Endpoints + + pins []*Pin + regardedPins []*Pin + + regionalMinLanes int + regionalMaxLanesOnHub int + satelliteMinLanes int + internalMinLanesOnHub int + internalMaxHops int +} + +func (region *Region) getName() string { + switch { + case region == nil: + return "-" + case region.Name != "": + return region.Name + default: + return region.ID + } +} + +func (m *Map) updateRegions(config []*hub.RegionConfig) { + // Reset map and pins. + m.regions = make([]*Region, 0, len(config)) + for _, pin := range m.all { + pin.region = nil + } + + // Stop if not regions are defined. + if len(config) == 0 { + return + } + + // Build regions from config. + for _, regionConfig := range config { + // Check if region has an ID. + if regionConfig.ID == "" { + log.Error("spn/navigator: region is missing ID") + // Abort adding this region to the map. + continue + } + + // Create new region. + region := &Region{ + ID: regionConfig.ID, + Name: regionConfig.Name, + config: regionConfig, + } + + // Parse member policy. + if len(regionConfig.MemberPolicy) == 0 { + log.Errorf("spn/navigator: member policy of region %s is missing", region.ID) + // Abort adding this region to the map. + continue + } + memberPolicy, err := endpoints.ParseEndpoints(regionConfig.MemberPolicy) + if err != nil { + log.Errorf("spn/navigator: failed to parse member policy of region %s: %s", region.ID, err) + // Abort adding this region to the map. + continue + } + region.memberPolicy = memberPolicy + + // Recalculate region properties. + region.recalculateProperties() + + // Add region to map. + m.regions = append(m.regions, region) + } + + // Update region in all Pins. + for _, pin := range m.all { + m.updatePinRegion(pin) + } +} + +func (region *Region) addPin(pin *Pin) { + // Find pin in region. + for _, regionPin := range region.pins { + if pin.Hub.ID == regionPin.Hub.ID { + // Pin is already part of region. + return + } + } + + // Check if pin is already part of this region. + if pin.region != nil && pin.region.ID == region.ID { + return + } + + // Remove pin from previous region. + if pin.region != nil { + pin.region.removePin(pin) + } + + // Add new pin to region. + region.pins = append(region.pins, pin) + pin.region = region + + // Recalculate region properties. + region.recalculateProperties() +} + +func (region *Region) removePin(pin *Pin) { + // Find pin index in region. + removeIndex := -1 + for index, regionPin := range region.pins { + if pin.Hub.ID == regionPin.Hub.ID { + removeIndex = index + break + } + } + if removeIndex < 0 { + // Pin is not part of region. + return + } + + // Remove pin from region. + region.pins = append(region.pins[:removeIndex], region.pins[removeIndex+1:]...) + + // Recalculate region properties. + region.recalculateProperties() +} + +func (region *Region) recalculateProperties() { + // Regional properties. + region.regionalMinLanes = calculateMinLanes( + len(region.pins), + region.config.RegionalMinLanes, + region.config.RegionalMinLanesPerHub, + defaultRegionalMinLanesPerHub, + ) + region.regionalMaxLanesOnHub = region.config.RegionalMaxLanesOnHub + if region.regionalMaxLanesOnHub <= 0 { + region.regionalMaxLanesOnHub = defaultRegionalMaxLanesOnHub + } + + // Satellite properties. + region.satelliteMinLanes = calculateMinLanes( + len(region.pins), + region.config.SatelliteMinLanes, + region.config.SatelliteMinLanesPerHub, + defaultSatelliteMinLanesPerHub, + ) + + // Internal properties. + region.internalMinLanesOnHub = region.config.InternalMinLanesOnHub + if region.internalMinLanesOnHub <= 0 { + region.internalMinLanesOnHub = defaultInternalMinLanesOnHub + } + region.internalMaxHops = region.config.InternalMaxHops + if region.internalMaxHops <= 0 { + region.internalMaxHops = defaultInternalMaxHops + } + // Values below 2 do not make any sense for max hops. + if region.internalMaxHops < 2 { + region.internalMaxHops = 2 + } +} + +func calculateMinLanes(regionHubCount, minLanes int, minLanesPerHub, defaultMinLanesPerHub float64) (minLaneCount int) { + // Validate hub count. + if regionHubCount <= 0 { + // Reset to safe value. + regionHubCount = 1 + } + + // Set to configured minimum lanes. + minLaneCount = minLanes + + // Raise to configured minimum lanes per Hub. + if minLanesPerHub != 0 { + minLanesFromSize := int(math.Ceil(float64(regionHubCount) * minLanesPerHub)) + if minLanesFromSize > minLaneCount { + minLaneCount = minLanesFromSize + } + } + + // Raise to default minimum lanes per Hub, if still 0. + if minLaneCount <= 0 { + minLaneCount = int(math.Ceil(float64(regionHubCount) * defaultMinLanesPerHub)) + } + + return minLaneCount +} + +func (m *Map) updatePinRegion(pin *Pin) { + for _, region := range m.regions { + // Check if pin matches the region's member policy. + if pin.EntityV4 != nil { + result, _ := region.memberPolicy.Match(context.TODO(), pin.EntityV4) + if result == endpoints.Permitted { + region.addPin(pin) + return + } + } + if pin.EntityV6 != nil { + result, _ := region.memberPolicy.Match(context.TODO(), pin.EntityV6) + if result == endpoints.Permitted { + region.addPin(pin) + return + } + } + } +} diff --git a/spn/navigator/route.go b/spn/navigator/route.go new file mode 100644 index 00000000..f1b98a38 --- /dev/null +++ b/spn/navigator/route.go @@ -0,0 +1,221 @@ +package navigator + +import ( + "fmt" + mrand "math/rand" + "sort" + "strings" + "time" +) + +// Routes holds a collection of Routes. +type Routes struct { + All []*Route + randomizeTopPercent float32 + maxCost float32 // automatic + maxRoutes int // manual setting +} + +// Len is the number of elements in the collection. +func (r *Routes) Len() int { + return len(r.All) +} + +// Less reports whether the element with index i should sort before the element +// with index j. +func (r *Routes) Less(i, j int) bool { + return r.All[i].TotalCost < r.All[j].TotalCost +} + +// Swap swaps the elements with indexes i and j. +func (r *Routes) Swap(i, j int) { + r.All[i], r.All[j] = r.All[j], r.All[i] +} + +// isGoodEnough reports whether the route would survive a clean process. +func (r *Routes) isGoodEnough(route *Route) bool { + if r.maxCost > 0 && route.TotalCost > r.maxCost { + return false + } + return true +} + +// add adds a Route if it is good enough. +func (r *Routes) add(route *Route) { + if !r.isGoodEnough(route) { + return + } + r.All = append(r.All, route.CopyUpTo(0)) + r.clean() +} + +// clean sort and shortens the list to the configured maximum. +func (r *Routes) clean() { + // Sort Routes so that the best ones are on top. + sort.Sort(r) + // Remove all remaining from the list. + if len(r.All) > r.maxRoutes { + r.All = r.All[:r.maxRoutes] + } + // Set new maximum total cost. + if len(r.All) >= r.maxRoutes { + r.maxCost = r.All[len(r.All)-1].TotalCost + } +} + +// randomizeTop randomized to the top nearest pins for balancing the network. +func (r *Routes) randomizeTop() { + switch { + case r.randomizeTopPercent == 0: + // Check if randomization is enabled. + return + case len(r.All) < 2: + // Check if we have enough pins to work with. + return + } + + // Find randomization set. + randomizeUpTo := len(r.All) + threshold := r.All[0].TotalCost * (1 + r.randomizeTopPercent) + for i, r := range r.All { + // Find first value above the threshold to stop. + if r.TotalCost > threshold { + randomizeUpTo = i + break + } + } + + // Shuffle top set. + if randomizeUpTo >= 2 { + mr := mrand.New(mrand.NewSource(time.Now().UnixNano())) //nolint:gosec + mr.Shuffle(randomizeUpTo, r.Swap) + } +} + +// Route is a path through the map. +type Route struct { + // Path is a list of Transit Hubs and the Destination Hub, including the Cost + // for each Hop. + Path []*Hop + + // DstCost is the calculated cost between the Destination Hub and the destination IP. + DstCost float32 + + // TotalCost is the sum of all costs of this Route. + TotalCost float32 + + // Algorithm is the ID of the algorithm used to calculate the route. + Algorithm string +} + +// Hop is one hop of a route's path. +type Hop struct { + pin *Pin + + // HubID is the Hub ID. + HubID string + + // Cost is the cost for both Lane to this Hub and the Hub itself. + Cost float32 +} + +// addHop adds a hop to the route. +func (r *Route) addHop(pin *Pin, cost float32) { + r.Path = append(r.Path, &Hop{ + pin: pin, + Cost: cost, + }) + r.recalculateTotalCost() +} + +// completeRoute completes the route by adding the destination cost of the +// connection between the last hop and the destination IP. +func (r *Route) completeRoute(dstCost float32) { + r.DstCost = dstCost + r.recalculateTotalCost() +} + +// removeHop removes the last hop from the Route. +func (r *Route) removeHop() { + // Reset DstCost, as the route might have been completed. + r.DstCost = 0 + + if len(r.Path) >= 1 { + r.Path = r.Path[:len(r.Path)-1] + } + r.recalculateTotalCost() +} + +// recalculateTotalCost recalculates to total cost of this route. +func (r *Route) recalculateTotalCost() { + r.TotalCost = r.DstCost + for _, hop := range r.Path { + if hop.pin.HasActiveTerminal() { + // If we have an active connection, only take 80% of the cost. + r.TotalCost += hop.Cost * 0.8 + } else { + r.TotalCost += hop.Cost + } + } +} + +// CopyUpTo makes a somewhat deep copy of the Route up to the specified amount +// and returns it. Hops themselves are not copied, because their data does not +// change. Therefore, returned Hops may not be edited. +// Specify an amount of 0 to copy all. +func (r *Route) CopyUpTo(n int) *Route { + // Check amount. + if n == 0 || n > len(r.Path) { + n = len(r.Path) + } + + newRoute := &Route{ + Path: make([]*Hop, n), + DstCost: r.DstCost, + TotalCost: r.TotalCost, + } + copy(newRoute.Path, r.Path) + return newRoute +} + +// makeExportReady fills in all the missing data fields which are meant for +// exporting only. +func (r *Routes) makeExportReady(algorithm string) { + for _, route := range r.All { + route.makeExportReady(algorithm) + } +} + +// makeExportReady fills in all the missing data fields which are meant for +// exporting only. +func (r *Route) makeExportReady(algorithm string) { + r.Algorithm = algorithm + for _, hop := range r.Path { + hop.makeExportReady() + } +} + +// makeExportReady fills in all the missing data fields which are meant for +// exporting only. +func (hop *Hop) makeExportReady() { + hop.HubID = hop.pin.Hub.ID +} + +// Pin returns the Pin of the Hop. +func (hop *Hop) Pin() *Pin { + return hop.pin +} + +func (r *Route) String() string { + s := make([]string, 0, len(r.Path)+2) + s = append(s, fmt.Sprintf("route with %.2fc:", r.TotalCost)) + for i, hop := range r.Path { + if i == 0 { + s = append(s, hop.pin.String()) + } else { + s = append(s, fmt.Sprintf("--> %.2fc %s", hop.Cost, hop.pin)) + } + } + s = append(s, fmt.Sprintf("--> %.2fc", r.DstCost)) + return strings.Join(s, " ") +} diff --git a/spn/navigator/routing-profiles.go b/spn/navigator/routing-profiles.go new file mode 100644 index 00000000..9241c072 --- /dev/null +++ b/spn/navigator/routing-profiles.go @@ -0,0 +1,162 @@ +package navigator + +import ( + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/profile" +) + +// RoutingProfile defines a routing algorithm with some options. +type RoutingProfile struct { + ID string + + // Name is the human readable name of the profile. + Name string + + // MinHops defines how many hops a route must have at minimum. In order to + // reduce confusion, the Home Hub is also counted. + MinHops int + + // MaxHops defines the limit on how many hops a route may have. In order to + // reduce confusion, the Home Hub is also counted. + MaxHops int + + // MaxExtraHops sets a limit on how many extra hops are allowed in addition + // to the amount of Hops in the currently best route. This is an optimization + // option and should not interfere with finding the best route, but might + // reduce the amount of routes found. + MaxExtraHops int + + // MaxExtraCost sets a limit on the extra cost allowed in addition to the + // cost of the currently best route. This is an optimization option and + // should not interfere with finding the best route, but might reduce the + // amount of routes found. + MaxExtraCost float32 +} + +// Routing Profile Names. +const ( + RoutingProfileHomeID = "home" + RoutingProfileSingleHopID = "single-hop" + RoutingProfileDoubleHopID = "double-hop" + RoutingProfileTripleHopID = "triple-hop" +) + +// Routing Profiles. +var ( + DefaultRoutingProfileID = profile.DefaultRoutingProfileID + + RoutingProfileHome = &RoutingProfile{ + ID: "home", + Name: "Plain VPN Mode", + MinHops: 1, + MaxHops: 1, + } + RoutingProfileSingleHop = &RoutingProfile{ + ID: "single-hop", + Name: "Speed Focused", + MinHops: 1, + MaxHops: 3, + MaxExtraHops: 1, + MaxExtraCost: 10000, + } + RoutingProfileDoubleHop = &RoutingProfile{ + ID: "double-hop", + Name: "Balanced", + MinHops: 2, + MaxHops: 4, + MaxExtraHops: 2, + MaxExtraCost: 10000, + } + RoutingProfileTripleHop = &RoutingProfile{ + ID: "triple-hop", + Name: "Privacy Focused", + MinHops: 3, + MaxHops: 5, + MaxExtraHops: 3, + MaxExtraCost: 10000, + } +) + +// GetRoutingProfile returns the routing profile with the given ID. +func GetRoutingProfile(id string) *RoutingProfile { + switch id { + case RoutingProfileHomeID: + return RoutingProfileHome + case RoutingProfileSingleHopID: + return RoutingProfileSingleHop + case RoutingProfileDoubleHopID: + return RoutingProfileDoubleHop + case RoutingProfileTripleHopID: + return RoutingProfileTripleHop + default: + return RoutingProfileDoubleHop + } +} + +type routeCompliance uint8 + +const ( + routeOk routeCompliance = iota // Route is fully compliant and can be used. + routeNonCompliant // Route is not compliant, but this might change if more hops are added. + routeDisqualified // Route is disqualified and won't be able to become compliant. +) + +func (rp *RoutingProfile) checkRouteCompliance(route *Route, foundRoutes *Routes) routeCompliance { + switch { + case len(route.Path) < rp.MinHops: + // Route is shorter than the defined minimum. + return routeNonCompliant + case len(route.Path) > rp.MaxHops: + // Route is longer than the defined maximum. + return routeDisqualified + } + + // Check for hub re-use. + if len(route.Path) >= 2 { + lastHop := route.Path[len(route.Path)-1] + for _, hop := range route.Path[:len(route.Path)-1] { + if lastHop.pin.Hub.ID == hop.pin.Hub.ID { + return routeDisqualified + } + } + } + + // Check if hub is already in use, if so check if the route matches. + if len(route.Path) >= 2 { + // Get active connection to the last pin of the current path. + lastPinConnection := route.Path[len(route.Path)-1].pin.Connection + + switch { + case lastPinConnection == nil: + // Last pin is not yet connected. + case len(lastPinConnection.Route.Path) < 2: + // Path of last pin does not have enough hops. + // This is unexpected and should not happen. + log.Errorf( + "navigator: expected active connection to %s to have 2 hops or more on path, but it had %d", + route.Path[len(route.Path)-1].pin.Hub.StringWithoutLocking(), + len(lastPinConnection.Route.Path), + ) + case lastPinConnection.Route.Path[len(lastPinConnection.Route.Path)-2].pin.Hub.ID != route.Path[len(route.Path)-2].pin.Hub.ID: + // The previous hop of the existing route and the one we are evaluating don't match. + // Currently, we only allow one session per Hub. + return routeDisqualified + } + } + + // Abort route exploration when we are outside the optimization boundaries. + if len(foundRoutes.All) > 0 { + // Get the best found route. + best := foundRoutes.All[0] + // Abort if current route exceeds max extra costs. + if route.TotalCost > best.TotalCost+rp.MaxExtraCost { + return routeDisqualified + } + // Abort if current route exceeds max extra hops. + if len(route.Path) > len(best.Path)+rp.MaxExtraHops { + return routeDisqualified + } + } + + return routeOk +} diff --git a/spn/navigator/sort.go b/spn/navigator/sort.go new file mode 100644 index 00000000..9fd0391e --- /dev/null +++ b/spn/navigator/sort.go @@ -0,0 +1,141 @@ +package navigator + +type sortByPinID []*Pin + +func (a sortByPinID) Len() int { return len(a) } +func (a sortByPinID) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortByPinID) Less(i, j int) bool { return a[i].Hub.ID < a[j].Hub.ID } + +type sortByLowestMeasuredCost []*Pin + +func (a sortByLowestMeasuredCost) Len() int { return len(a) } +func (a sortByLowestMeasuredCost) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortByLowestMeasuredCost) Less(i, j int) bool { + x := a[i].measurements.GetCalculatedCost() + y := a[j].measurements.GetCalculatedCost() + if x != y { + return x < y + } + + // Fall back to geo proximity. + gx := a[i].measurements.GetGeoProximity() + gy := a[j].measurements.GetGeoProximity() + if gx != gy { + return gx > gy + } + + // Fall back to Hub ID. + return a[i].Hub.ID < a[j].Hub.ID +} + +type sortBySuggestedHopDistanceAndLowestMeasuredCost []*Pin + +func (a sortBySuggestedHopDistanceAndLowestMeasuredCost) Len() int { return len(a) } +func (a sortBySuggestedHopDistanceAndLowestMeasuredCost) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortBySuggestedHopDistanceAndLowestMeasuredCost) Less(i, j int) bool { + // First sort by suggested hop distance. + if a[i].analysis.SuggestedHopDistance != a[j].analysis.SuggestedHopDistance { + return a[i].analysis.SuggestedHopDistance > a[j].analysis.SuggestedHopDistance + } + + // Then by cost. + x := a[i].measurements.GetCalculatedCost() + y := a[j].measurements.GetCalculatedCost() + if x != y { + return x < y + } + + // Fall back to geo proximity. + gx := a[i].measurements.GetGeoProximity() + gy := a[j].measurements.GetGeoProximity() + if gx != gy { + return gx > gy + } + + // Fall back to Hub ID. + return a[i].Hub.ID < a[j].Hub.ID +} + +type sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost []*Pin + +func (a sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost) Len() int { return len(a) } +func (a sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a sortBySuggestedHopDistanceInRegionAndLowestMeasuredCost) Less(i, j int) bool { + // First sort by suggested hop distance. + if a[i].analysis.SuggestedHopDistanceInRegion != a[j].analysis.SuggestedHopDistanceInRegion { + return a[i].analysis.SuggestedHopDistanceInRegion > a[j].analysis.SuggestedHopDistanceInRegion + } + + // Then by cost. + x := a[i].measurements.GetCalculatedCost() + y := a[j].measurements.GetCalculatedCost() + if x != y { + return x < y + } + + // Fall back to geo proximity. + gx := a[i].measurements.GetGeoProximity() + gy := a[j].measurements.GetGeoProximity() + if gx != gy { + return gx > gy + } + + // Fall back to Hub ID. + return a[i].Hub.ID < a[j].Hub.ID +} + +type sortByLowestMeasuredLatency []*Pin + +func (a sortByLowestMeasuredLatency) Len() int { return len(a) } +func (a sortByLowestMeasuredLatency) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortByLowestMeasuredLatency) Less(i, j int) bool { + x, _ := a[i].measurements.GetLatency() + y, _ := a[j].measurements.GetLatency() + switch { + case x == y: + // Go to fallbacks. + case x == 0: + // Ignore zero values. + return false // j/y is better. + case y == 0: + // Ignore zero values. + return true // i/x is better. + default: + return x < y + } + + // Fall back to geo proximity. + gx := a[i].measurements.GetGeoProximity() + gy := a[j].measurements.GetGeoProximity() + if gx != gy { + return gx > gy + } + + // Fall back to Hub ID. + return a[i].Hub.ID < a[j].Hub.ID +} + +type sortByHighestMeasuredCapacity []*Pin + +func (a sortByHighestMeasuredCapacity) Len() int { return len(a) } +func (a sortByHighestMeasuredCapacity) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortByHighestMeasuredCapacity) Less(i, j int) bool { + x, _ := a[i].measurements.GetCapacity() + y, _ := a[j].measurements.GetCapacity() + if x != y { + return x > y + } + + // Fall back to geo proximity. + gx := a[i].measurements.GetGeoProximity() + gy := a[j].measurements.GetGeoProximity() + if gx != gy { + return gx > gy + } + + // Fall back to Hub ID. + return a[i].Hub.ID < a[j].Hub.ID +} diff --git a/spn/navigator/sort_test.go b/spn/navigator/sort_test.go new file mode 100644 index 00000000..f424cc3d --- /dev/null +++ b/spn/navigator/sort_test.go @@ -0,0 +1,112 @@ +package navigator + +import ( + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safing/portmaster/spn/hub" +) + +func TestSorting(t *testing.T) { + t.Parallel() + + list := []*Pin{ + { + Hub: &hub.Hub{ + ID: "a", + }, + measurements: &hub.Measurements{ + Latency: 3, + Capacity: 4, + CalculatedCost: 5, + }, + analysis: &AnalysisState{ + SuggestedHopDistance: 3, + }, + }, + { + Hub: &hub.Hub{ + ID: "b", + }, + measurements: &hub.Measurements{ + Latency: 4, + Capacity: 3, + CalculatedCost: 1, + }, + analysis: &AnalysisState{ + SuggestedHopDistance: 2, + }, + }, + { + Hub: &hub.Hub{ + ID: "c", + }, + measurements: &hub.Measurements{ + Latency: 5, + Capacity: 2, + CalculatedCost: 2, + }, + analysis: &AnalysisState{ + SuggestedHopDistance: 4, + }, + }, + { + Hub: &hub.Hub{ + ID: "d", + }, + measurements: &hub.Measurements{ + Latency: 1, + Capacity: 1, + CalculatedCost: 3, + }, + analysis: &AnalysisState{ + SuggestedHopDistance: 4, + }, + }, + { + Hub: &hub.Hub{ + ID: "e", + }, + measurements: &hub.Measurements{ + Latency: 2, + Capacity: 5, + CalculatedCost: 4, + }, + analysis: &AnalysisState{ + SuggestedHopDistance: 4, + }, + }, + } + + sort.Sort(sortByLowestMeasuredCost(list)) + checkSorting(t, list, "b-c-d-e-a") + + sort.Sort(sortBySuggestedHopDistanceAndLowestMeasuredCost(list)) + checkSorting(t, list, "c-d-e-a-b") + + sort.Sort(sortByLowestMeasuredLatency(list)) + checkSorting(t, list, "d-e-a-b-c") + + sort.Sort(sortByHighestMeasuredCapacity(list)) + checkSorting(t, list, "e-a-b-c-d") + + sort.Sort(sortByPinID(list)) + checkSorting(t, list, "a-b-c-d-e") +} + +func checkSorting(t *testing.T, sortedList []*Pin, expectedOrder string) { + t.Helper() + + // Build list ID string. + ids := make([]string, 0, len(sortedList)) + for _, pin := range sortedList { + ids = append(ids, pin.Hub.ID) + } + sortedIDs := strings.Join(ids, "-") + + // Check for matching order. + assert.Equal(t, expectedOrder, sortedIDs, "should match") +} diff --git a/spn/navigator/state.go b/spn/navigator/state.go new file mode 100644 index 00000000..755e2895 --- /dev/null +++ b/spn/navigator/state.go @@ -0,0 +1,426 @@ +package navigator + +import ( + "strings" + "time" +) + +// PinState holds a bit-mapped collection of Pin states, or a single state used +// for assigment and matching. +type PinState uint16 + +const ( + // StateNone represents an empty state. + StateNone PinState = 0 + + // Negative States. + + // StateInvalid signifies that there was an error while processing or + // handling this Hub. + StateInvalid PinState = 1 << (iota - 1) // 1 << 0 => 00000001 => 0x01 + + // StateSuperseded signifies that this Hub was superseded by another. This is + // the case if any other Hub with a matching IP was verified after this one. + // Verification timestamp equals Hub.FirstSeen. + StateSuperseded // 0x02 + + // StateFailing signifies that a recent error was encountered while + // communicating with this Hub. Pin.FailingUntil specifies when this state is + // re-evaluated at earliest. + StateFailing // 0x04 + + // StateOffline signifies that the Hub is offline. + StateOffline // 0x08 + + // Positive States. + + // StateHasRequiredInfo signifies that the Hub announces the minimum required + // information about itself. + StateHasRequiredInfo // 0x10 + + // StateReachable signifies that the Hub is reachable via the network from + // the currently connected primary Hub. + StateReachable // 0x20 + + // StateActive signifies that everything seems fine with the Hub and + // connections to it should succeed. This is tested by checking if a valid + // semi-ephemeral public key is available. + StateActive // 0x40 + + _ // 0x80: Reserved + + // Trust and Advisory States. + + // StateTrusted signifies the Hub has the special trusted status. + StateTrusted // 0x0100 + + // StateUsageDiscouraged signifies that usage of the Hub is discouraged for any task. + StateUsageDiscouraged // 0x0200 + + // StateUsageAsHomeDiscouraged signifies that usage of the Hub as a Home Hub is discouraged. + StateUsageAsHomeDiscouraged // 0x0400 + + // StateUsageAsDestinationDiscouraged signifies that usage of the Hub as a Destination Hub is discouraged. + StateUsageAsDestinationDiscouraged // 0x0800 + + // Special States. + + // StateIsHomeHub signifies that the Hub is the current Home Hub. While not + // negative in itself, selecting the Home Hub does not make sense in almost + // all cases. + StateIsHomeHub // 0x1000 + + // StateConnectivityIssues signifies that the Hub reports connectivity issues. + // This might impact all connectivity or just some. + // This does not invalidate the Hub for all operations and not in all cases. + StateConnectivityIssues // 0x2000 + + // StateAllowUnencrypted signifies that the Hub is available to handle unencrypted connections. + StateAllowUnencrypted // 0x4000 + + // State Summaries. + + // StateSummaryRegard summarizes all states that must always be set in order to take a Hub into consideration for any task. + // TODO: Add StateHasRequiredInfo when we start enforcing Hub information. + StateSummaryRegard = StateReachable | StateActive + + // StateSummaryDisregard summarizes all states that must not be set in order to take a Hub into consideration for any task. + StateSummaryDisregard = StateInvalid | + StateSuperseded | + StateFailing | + StateOffline | + StateUsageDiscouraged | + StateIsHomeHub +) + +var allStates = []PinState{ + StateInvalid, + StateSuperseded, + StateFailing, + StateOffline, + StateHasRequiredInfo, + StateReachable, + StateActive, + StateTrusted, + StateUsageDiscouraged, + StateUsageAsHomeDiscouraged, + StateUsageAsDestinationDiscouraged, + StateIsHomeHub, + StateConnectivityIssues, + StateAllowUnencrypted, +} + +// Add returns a new PinState with the given states added. +func (pinState PinState) Add(states PinState) PinState { + // OR: + // 0011 + // | 0101 + // = 0111 + return pinState | states +} + +// Remove returns a new PinState with the given states removed. +func (pinState PinState) Remove(states PinState) PinState { + // AND NOT: + // 0011 + // &^ 0101 + // = 0010 + return pinState &^ states +} + +// Has returns whether the state has all of the given states. +func (pinState PinState) Has(states PinState) bool { + // AND: + // 0011 + // & 0101 + // = 0001 + + return pinState&states == states +} + +// HasAnyOf returns whether the state has any of the given states. +func (pinState PinState) HasAnyOf(states PinState) bool { + // AND: + // 0011 + // & 0101 + // = 0001 + + return (pinState & states) != 0 +} + +// HasNoneOf returns whether the state does not have any of the given states. +func (pinState PinState) HasNoneOf(states PinState) bool { + // AND: + // 0011 + // & 0101 + // = 0001 + + return (pinState & states) == 0 +} + +// addStates adds the given states on the Pin. +func (pin *Pin) addStates(states PinState) { + pin.State = pin.State.Add(states) +} + +// removeStates removes the given states on the Pin. +func (pin *Pin) removeStates(states PinState) { + pin.State = pin.State.Remove(states) +} + +func (m *Map) updateStateSuperseded(pin *Pin) { + pin.removeStates(StateSuperseded) + + // Update StateSuperseded + // Iterate over all Pins in order to find a matching IP address. + // In order to prevent false positive matching, we have to go through IPv4 + // and IPv6 separately. + // TODO: This will not scale well beyond about 1000 Hubs. + + // IPv4 Loop + if pin.Hub.Info.IPv4 != nil { + for _, mapPin := range m.all { + // Skip Pin itself + if mapPin.Hub.ID == pin.Hub.ID { + continue + } + + // Check for a matching IPv4 address. + if mapPin.Hub.Info.IPv4 != nil && pin.Hub.Info.IPv4.Equal(mapPin.Hub.Info.IPv4) { + continueChecking := checkAndHandleSuperseding(pin, mapPin) + if !continueChecking { + break + } + } + } + } + + // IPv6 Loop + if pin.Hub.Info.IPv6 != nil { + for _, mapPin := range m.all { + // Skip Pin itself + if mapPin.Hub.ID == pin.Hub.ID { + continue + } + + // Check for a matching IPv6 address. + if mapPin.Hub.Info.IPv6 != nil && pin.Hub.Info.IPv6.Equal(mapPin.Hub.Info.IPv6) { + continueChecking := checkAndHandleSuperseding(pin, mapPin) + if !continueChecking { + break + } + } + } + } +} + +func checkAndHandleSuperseding(newPin, existingPin *Pin) (continueChecking bool) { + const ( + supersedeNone = iota + supersedeExisting + supersedeNew + ) + var action int + + switch { + case newPin.Hub.ID == existingPin.Hub.ID: + // Cannot supersede same Hub. + // Continue checking. + action = supersedeNone + + // Step 1: Check if only one is active. + + case newPin.State.Has(StateActive) && existingPin.State.HasNoneOf(StateActive): + // If only the new Hub is active, supersede the existing one. + action = supersedeExisting + case newPin.State.HasNoneOf(StateActive) && existingPin.State.Has(StateActive): + // If only the existing Hub is active, supersede the new one. + action = supersedeNew + + // Step 2: Check if only one is reachable. + + case newPin.State.Has(StateReachable) && existingPin.State.HasNoneOf(StateReachable): + // If only the new Hub is reachable, supersede the existing one. + action = supersedeExisting + case newPin.State.HasNoneOf(StateReachable) && existingPin.State.Has(StateReachable): + // If only the existing Hub is reachable, supersede the new one. + action = supersedeNew + + // Step 3: Check which one has been seen first. + + case newPin.Hub.FirstSeen.After(existingPin.Hub.FirstSeen): + // If the new Hub has been first seen later, supersede the existing one. + action = supersedeExisting + default: + // If the existing Hub has been first seen later, supersede the new one. + action = supersedeNew + } + + switch action { + case supersedeExisting: + existingPin.addStates(StateSuperseded) + existingPin.pushChanges.Set() + // Continue checking, as there might be other Hubs to be superseded. + return true + + case supersedeNew: + newPin.addStates(StateSuperseded) + newPin.pushChanges.Set() + // If the new pin is superseded, do _not_ continue, as this will lead to an incorrect state. + return false + + case supersedeNone: + fallthrough + default: + // Do nothing, continue checking. + return true + } +} + +func (pin *Pin) updateStateHasRequiredInfo() { + pin.removeStates(StateHasRequiredInfo) + + // Check for required Hub Information. + switch { + case len(pin.Hub.Info.Name) == 0: + case len(pin.Hub.Info.Group) == 0: + case len(pin.Hub.Info.ContactAddress) == 0: + case len(pin.Hub.Info.ContactService) == 0: + case len(pin.Hub.Info.Hosters) == 0: + case len(pin.Hub.Info.Hosters[0]) == 0: + case len(pin.Hub.Info.Datacenter) == 0: + default: + pin.addStates(StateHasRequiredInfo) + } +} + +func (m *Map) updateActiveHubs() { + now := time.Now().Unix() + for _, pin := range m.all { + pin.updateStateActive(now) + } +} + +func (pin *Pin) updateStateActive(now int64) { + pin.removeStates(StateActive) + + // Check for active key. + for _, key := range pin.Hub.Status.Keys { + if now < key.Expires { + pin.addStates(StateActive) + return + } + } +} + +func (m *Map) recalculateReachableHubs() error { + if m.home == nil { + return ErrHomeHubUnset + } + + // reset + for _, pin := range m.all { + pin.removeStates(StateReachable) + pin.HopDistance = 0 + pin.pushChanges.Set() + } + + // find all connected Hubs + m.home.markReachable(1) + return nil +} + +func (pin *Pin) markReachable(hopDistance int) { + switch { + case !pin.State.Has(StateReachable): + // Pin wasn't reachable before. + case hopDistance < pin.HopDistance: + // New path has a shorter distance. + case pin.State.HasAnyOf(StateSummaryDisregard): //nolint:staticcheck + // Ignore disregarded pins for reachability calculation. + return + default: + // Pin is already reachable at same or better distance. + return + } + + // Update reachability. + pin.addStates(StateReachable) + pin.HopDistance = hopDistance + pin.pushChanges.Set() + + // Propagate to connected Pins. + hopDistance++ + for _, lane := range pin.ConnectedTo { + lane.Pin.markReachable(hopDistance) + } +} + +// Export returns a list of all state names. +func (pinState PinState) Export() []string { + // Check if there are no states. + if pinState == StateNone { + return nil + } + + // Collect state names. + var stateNames []string + for _, state := range allStates { + if pinState.Has(state) { + stateNames = append(stateNames, state.Name()) + } + } + + return stateNames +} + +// String returns the states as a human readable string. +func (pinState PinState) String() string { + stateNames := pinState.Export() + if len(stateNames) == 0 { + return "None" + } + + return strings.Join(stateNames, ", ") +} + +// Name returns the name of a single state flag. +func (pinState PinState) Name() string { + switch pinState { + case StateNone: + return "None" + case StateInvalid: + return "Invalid" + case StateSuperseded: + return "Superseded" + case StateFailing: + return "Failing" + case StateOffline: + return "Offline" + case StateHasRequiredInfo: + return "HasRequiredInfo" + case StateReachable: + return "Reachable" + case StateActive: + return "Active" + case StateTrusted: + return "Trusted" + case StateUsageDiscouraged: + return "UsageDiscouraged" + case StateUsageAsHomeDiscouraged: + return "UsageAsHomeDiscouraged" + case StateUsageAsDestinationDiscouraged: + return "UsageAsDestinationDiscouraged" + case StateIsHomeHub: + return "IsHomeHub" + case StateConnectivityIssues: + return "ConnectivityIssues" + case StateAllowUnencrypted: + return "AllowUnencrypted" + case StateSummaryRegard, StateSummaryDisregard: + // Satisfy exhaustive linter. + fallthrough + default: + return "Unknown" + } +} diff --git a/spn/navigator/state_test.go b/spn/navigator/state_test.go new file mode 100644 index 00000000..90d5f37a --- /dev/null +++ b/spn/navigator/state_test.go @@ -0,0 +1,31 @@ +package navigator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStates(t *testing.T) { + t.Parallel() + + p := &Pin{} + + p.addStates(StateInvalid | StateFailing | StateSuperseded) + assert.Equal(t, StateInvalid|StateFailing|StateSuperseded, p.State) + + p.removeStates(StateFailing | StateSuperseded) + assert.Equal(t, StateInvalid, p.State) + + p.addStates(StateTrusted | StateActive) + assert.True(t, p.State.Has(StateInvalid|StateTrusted)) + assert.False(t, p.State.Has(StateInvalid|StateSuperseded)) + assert.True(t, p.State.HasAnyOf(StateInvalid|StateTrusted)) + assert.True(t, p.State.HasAnyOf(StateInvalid|StateSuperseded)) + assert.False(t, p.State.HasAnyOf(StateSuperseded|StateFailing)) + + assert.False(t, p.State.Has(StateSummaryRegard)) + assert.False(t, p.State.Has(StateSummaryDisregard)) + assert.True(t, p.State.HasAnyOf(StateSummaryRegard)) + assert.True(t, p.State.HasAnyOf(StateSummaryDisregard)) +} diff --git a/spn/navigator/testdata/main-intel.yml b/spn/navigator/testdata/main-intel.yml new file mode 100644 index 00000000..62711337 --- /dev/null +++ b/spn/navigator/testdata/main-intel.yml @@ -0,0 +1,234 @@ +--- +BootstrapHubs: +- tcp://[2a01:4f8:172:3753::2]:17#Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC # fogos [DE] +- tcp://[2a01:4f9:2a:d48::2]:17#Zwkwujs345P4ZygNZcEafawTqfZieCBVogQZ3xZPWiu7BU # heleus [FI] +- tcp://138.201.140.70:17#Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC # fogos [DE] +- tcp://95.216.13.61:17#Zwkwujs345P4ZygNZcEafawTqfZieCBVogQZ3xZPWiu7BU # heleus [FI] + +Hubs: + ZwhpYS1jWzXvPYKFhJqh1ZD3bKquLLoSoJ6RjeshmcXoFx: # voria [US] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: US + Coordinates: # Ashburn, VA + Latitude: 39.04 + Longitude: -77.48 + AccuracyRadius: 20 + ZwkAKBoyEd3PkE5RGDNmghahzHiBiTZA7Mg3XH7X3HjS39: # noru [US] + Trusted: true + VerifiedOwner: Safing + ZwkapJz5HFWpgd9PHsZLVueBu9PDmTJHKp382Wm9MB2EB7: # lovas [US] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: US + Coordinates: # Los Angeles, CA + Latitude: 34.03 + Longitude: -118.15 + AccuracyRadius: 20 + ZwkLShvVYvQFGmpY1MNhSSPXCktojywMVtv2N86mFbNH4w: # tooina [CA] + Trusted: true + VerifiedOwner: Safing + Zwkwujs345P4ZygNZcEafawTqfZieCBVogQZ3xZPWiu7BU: # heleus [FI] + Trusted: true + VerifiedOwner: Safing + Zwm72XieV6aeNKbwtJW8JdPUwT1hopQaLanLXjxcTfV3B9: # mergan [US] + Trusted: true + VerifiedOwner: Safing + Zwmp5SgUK9FidWBSCDK4d6dyRp3vhz3dQdwma1E4TMfiRw: # grenenia [FR] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: FR + Coordinates: # Gravelines + Latitude: 50.59 + Longitude: 2.07 + AccuracyRadius: 20 + ZwnFd1bSQrBegPZqFkS7DZU29x4PbojpFmTQFUnzQoicKp: # telos [IL] + Trusted: true + VerifiedOwner: Safing + Zwpg5FoXYVYidzgbdvDyvBBcrArmmHvK9nH3v7KDHiywtt: # melcor [PL] + Trusted: true + VerifiedOwner: Safing + ZwpsJpwngWyba54AbVkCawcRQ2HP37RRQAgj5LHNR2svRf: # soalis [AU] + Trusted: true + VerifiedOwner: Safing + Zwpy5hbrQkKznJwbUmn9WpJwGkpWD9VqE2pi9yfMDQM7PK: # rin9 [FR] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: FR + Coordinates: # Strasbourg + Latitude: 48.35 + Longitude: 7.45 + AccuracyRadius: 20 + ZwqANMrhcyJZb8cRMEd3FdPcXY7ZbvviPPfTUQpLNau12J: # sulkam [GB] + Trusted: true + VerifiedOwner: Safing + ZwwBspMhigqcEYv2cryipzJsi4vkHhnBqUmDmkJ2xizGFx: # surn [US] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: US + Coordinates: # Seattle, WA + Latitude: 47.36 + Longitude: -122.19 + AccuracyRadius: 20 + ZwsvsES3SHz1VLnwFPxDbW6DC8Esp1PiEtUHxGnm4BTYHt: # fungvis [DE] + Trusted: true + VerifiedOwner: Safing + Zwtb8EKMatnMRkW1VaLh8CPV3QswD9iuRU4Sda8uLezUkC: # fogos [DS] + Trusted: true + VerifiedOwner: Safing + ZwtfvBuq5wkKYRth8rGCuGyp42nMe4doASUDJiDHJ8iucn: # vamalla [AT] + Trusted: true + VerifiedOwner: Safing + ZwtjwvdPxG4u7oB2zmNJFvsDy5VDLT9UArDkYDGfC9bkDt: # carros [US] + Trusted: true + VerifiedOwner: Safing + ZwvMZt6RcrrRuCdufjApnosxWbzsP8rTPRuHGeHu5KU241: # syniru [SG] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: SG + Coordinates: # Singapore + Latitude: 1.18 + Longitude: 103.50 + AccuracyRadius: 20 + ZwvyDLz8221fcSBw6GKZNDnwEn4YmE9m7JPieLUVe7iGR9: # calla [CA] + Trusted: true + VerifiedOwner: Safing + Zwvz9S6uyxn4ww1JGqJiisGMDmH2hz6mhwutmJXvTtwQww: # cidai [US] + Trusted: true + VerifiedOwner: Safing + ZwxJvZDZH18RUEQ3oFcR5uCqeXJaqkoi9P5Sj1aZ62HPin: # nutis [DE] + Trusted: true + VerifiedOwner: Safing + ZwvPQVFkoDbx3J6qThNwfLHZqwvFgUYLYtirCHVd7FfjBz: # perturn [CZ] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: CZ + Coordinates: # Prague + Latitude: 50.05 + Longitude: 14.25 + AccuracyRadius: 100 + Zwj52Q7d5ezvFk7HKB42dBtFu152bC9JasYF7BHB724RfG: # sono [NL] + Trusted: true + VerifiedOwner: Safing + ZwmhYMEmw36CzgVUp9sLjoK3gkVDWdMPiupEcekpTAXur8: # ivtos [TR] + Trusted: true + VerifiedOwner: Safing + Override: + CountryCode: TR + Coordinates: # Izmir + Latitude: 38.25 + Longitude: 27.90 + AccuracyRadius: 20 + Zwm9JX1hBNUUvSYc3gpMhmw84ay45SuyXE7D2UgETM7XCn: # porcania [PT] + Trusted: true + VerifiedOwner: Safing + ZwxE83uRV9LcM8Bm3QjXjjejNRhBBBJAethPf14R6gcZwf: # steepeus [SE] + Trusted: true + VerifiedOwner: Safing + +InfoOverrides: + workaround: + for: bug + +AdviseOnlyTrustedHubs: false +AdviseOnlyTrustedHomeHubs: true +AdviseOnlyTrustedDestinationHubs: false + +HomeHubAdvisory: +- "- Zwj52Q7d5ezvFk7HKB42dBtFu152bC9JasYF7BHB724RfG" # sono [NL] is too slow for home hub +- "- Zwm9JX1hBNUUvSYc3gpMhmw84ay45SuyXE7D2UgETM7XCn" # porcania [PT] is too slow for home hub +- "- ZwmhYMEmw36CzgVUp9sLjoK3gkVDWdMPiupEcekpTAXur8" # ivtos [TR] is too slow for home hub +- "- ZwvPQVFkoDbx3J6qThNwfLHZqwvFgUYLYtirCHVd7FfjBz" # perturn [CZ] is too slow for home hub + +Regions: +- ID: europe + Name: Europe + RegionalMinLanes: 5 + RegionalMinLanesPerHub: 0.7 + RegionalMaxLanesOnHub: 2 + SatelliteMinLanes: 2 + SatelliteMinLanesPerHub: 0.3 + InternalMinLanesOnHub: 3 + InternalMaxHops: 3 + MemberPolicy: + - "+ AD" + - "+ AL" + - "+ AT" + - "+ AX" + - "+ BA" + - "+ BE" + - "+ BG" + - "+ BY" + - "+ CH" + - "+ CZ" + - "+ DE" + - "+ DK" + - "+ EE" + - "+ ES" + - "+ FI" + - "+ FO" + - "+ FR" + - "+ GB" + - "+ GG" + - "+ GI" + - "+ GR" + - "+ HR" + - "+ HU" + - "+ IE" + - "+ IM" + - "+ IS" + - "+ IT" + - "+ JE" + - "+ LI" + - "+ LT" + - "+ LU" + - "+ LV" + - "+ MC" + - "+ MD" + - "+ ME" + - "+ MK" + - "+ MT" + - "+ NL" + - "+ NO" + - "+ PL" + - "+ PT" + - "+ RO" + - "+ RS" + - "+ RU" + - "+ SE" + - "+ SI" + - "+ SJ" + - "+ SK" + - "+ SM" + - "+ UA" + - "+ VA" +- ID: north-america + Name: "North America" + RegionalMinLanes: 5 + RegionalMinLanesPerHub: 0.7 + RegionalMaxLanesOnHub: 2 + SatelliteMinLanes: 2 + SatelliteMinLanesPerHub: 0.3 + InternalMinLanesOnHub: 3 + InternalMaxHops: 3 + MemberPolicy: + - "+ BM" + - "+ BZ" + - "+ CA" + - "+ CR" + - "+ GL" + - "+ GT" + - "+ HN" + - "+ MX" + - "+ NI" + - "+ PA" + - "+ PM" + - "+ SV" + - "+ US" \ No newline at end of file diff --git a/spn/navigator/update.go b/spn/navigator/update.go new file mode 100644 index 00000000..3fe17074 --- /dev/null +++ b/spn/navigator/update.go @@ -0,0 +1,776 @@ +package navigator + +import ( + "context" + "fmt" + "path" + "strings" + "time" + + "github.com/tevino/abool" + "golang.org/x/exp/slices" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/database" + "github.com/safing/portbase/database/query" + "github.com/safing/portbase/database/record" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/utils" + "github.com/safing/portmaster/service/intel/geoip" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/service/profile" + "github.com/safing/portmaster/spn/hub" +) + +var db = database.NewInterface(&database.Options{ + Local: true, + Internal: true, +}) + +// InitializeFromDatabase loads all Hubs from the given database prefix and adds them to the Map. +func (m *Map) InitializeFromDatabase() error { + m.Lock() + defer m.Unlock() + + // start query for Hubs + iter, err := db.Query(query.New(hub.MakeHubDBKey(m.Name, ""))) + if err != nil { + return fmt.Errorf("failed to start query for initialization feed of %s map: %w", m.Name, err) + } + + // update navigator + var hubCount int + log.Tracef("spn/navigator: starting to initialize %s map from database", m.Name) + for r := range iter.Next { + h, err := hub.EnsureHub(r) + if err != nil { + log.Warningf("spn/navigator: could not parse hub %q while initializing %s map: %s", r.Key(), m.Name, err) + continue + } + + hubCount++ + m.updateHub(h, false, true) + } + switch { + case iter.Err() != nil: + return fmt.Errorf("failed to (fully) initialize %s map: %w", m.Name, iter.Err()) + case hubCount == 0: + log.Warningf("spn/navigator: no hubs available for %s map - this is normal on first start", m.Name) + default: + log.Infof("spn/navigator: added %d hubs from database to %s map", hubCount, m.Name) + } + return nil +} + +// UpdateHook updates the a map from database changes. +type UpdateHook struct { + database.HookBase + m *Map +} + +// UsesPrePut implements the Hook interface. +func (hook *UpdateHook) UsesPrePut() bool { + return true +} + +// PrePut implements the Hook interface. +func (hook *UpdateHook) PrePut(r record.Record) (record.Record, error) { + // Remove deleted hubs from the map. + if r.Meta().IsDeleted() { + hook.m.RemoveHub(path.Base(r.Key())) + return r, nil + } + + // Ensure we have a hub and update it in navigation map. + h, err := hub.EnsureHub(r) + if err != nil { + log.Debugf("spn/navigator: record %s is not a hub", r.Key()) + } else { + hook.m.updateHub(h, true, false) + } + + return r, nil +} + +// RegisterHubUpdateHook registers a database pre-put hook that updates all +// Hubs saved at the given database prefix. +func (m *Map) RegisterHubUpdateHook() (err error) { + m.hubUpdateHook, err = database.RegisterHook( + query.New(hub.MakeHubDBKey(m.Name, "")), + &UpdateHook{m: m}, + ) + return err +} + +// CancelHubUpdateHook cancels the map's update hook. +func (m *Map) CancelHubUpdateHook() { + if m.hubUpdateHook != nil { + if err := m.hubUpdateHook.Cancel(); err != nil { + log.Warningf("spn/navigator: failed to cancel update hook for map %s: %s", m.Name, err) + } + } +} + +// RemoveHub removes a Hub from the Map. +func (m *Map) RemoveHub(id string) { + m.Lock() + defer m.Unlock() + + // Get pin and remove it from the map, if it exists. + pin, ok := m.all[id] + if !ok { + return + } + delete(m.all, id) + + // Remove lanes from removed Pin. + for id := range pin.ConnectedTo { + // Remove Lane from peer. + peer, ok := m.all[id] + if ok { + delete(peer.ConnectedTo, pin.Hub.ID) + peer.pushChanges.Set() + } + } + + // Push update to subscriptions. + export := pin.Export() + export.Meta().Delete() + mapDBController.PushUpdate(export) + // Push lane changes. + m.PushPinChanges() +} + +// UpdateHub updates a Hub on the Map. +func (m *Map) UpdateHub(h *hub.Hub) { + m.updateHub(h, true, true) +} + +func (m *Map) updateHub(h *hub.Hub, lockMap, lockHub bool) { + if lockMap { + m.Lock() + defer m.Unlock() + } + if lockHub { + h.Lock() + defer h.Unlock() + } + + // Hub requires both Info and Status to be added to the Map. + if h.Info == nil || h.Status == nil { + return + } + + // Create or update Pin. + pin, ok := m.all[h.ID] + if ok { + pin.Hub = h + } else { + pin = &Pin{ + Hub: h, + ConnectedTo: make(map[string]*Lane), + pushChanges: abool.New(), + } + m.all[h.ID] = pin + } + pin.pushChanges.Set() + + // 1. Update Pin Data. + + // Add/Update location data from IP addresses. + pin.updateLocationData() + + // Override Pin Data. + m.updateInfoOverrides(pin) + + // Update Hub cost. + pin.Cost = CalculateHubCost(pin.Hub.Status.Load) + + // Ensure measurements are set when enabled. + if m.measuringEnabled && pin.measurements == nil { + // Get shared measurements. + pin.measurements = pin.Hub.GetMeasurementsWithLockedHub() + + // Update cost calculation. + latency, _ := pin.measurements.GetLatency() + capacity, _ := pin.measurements.GetCapacity() + pin.measurements.SetCalculatedCost(CalculateLaneCost(latency, capacity)) + + // Update geo proximity. + // Get own location. + var myLocation *geoip.Location + switch { + case m.home != nil && m.home.LocationV4 != nil: + myLocation = m.home.LocationV4 + case m.home != nil && m.home.LocationV6 != nil: + myLocation = m.home.LocationV6 + default: + locations, ok := netenv.GetInternetLocation() + if ok { + myLocation = locations.Best().LocationOrNil() + } + } + // Calculate proximity with available location. + if myLocation != nil { + switch { + case pin.LocationV4 != nil: + pin.measurements.SetGeoProximity( + myLocation.EstimateNetworkProximity(pin.LocationV4), + ) + case pin.LocationV6 != nil: + pin.measurements.SetGeoProximity( + myLocation.EstimateNetworkProximity(pin.LocationV6), + ) + } + } + } + + // 2. Update Pin States. + + // Update the invalid status of the Pin. + if pin.Hub.InvalidInfo || pin.Hub.InvalidStatus { + pin.addStates(StateInvalid) + } else { + pin.removeStates(StateInvalid) + } + + // Update online status of the Pin. + if pin.Hub.HasFlag(hub.FlagOffline) || pin.Hub.Status.Version == hub.VersionOffline { + pin.addStates(StateOffline) + } else { + pin.removeStates(StateOffline) + } + + // Update online status of the Pin. + if pin.Hub.HasFlag(hub.FlagAllowUnencrypted) { + pin.addStates(StateAllowUnencrypted) + } else { + pin.removeStates(StateAllowUnencrypted) + } + + // Update from status flags. + if pin.Hub.HasFlag(hub.FlagNetError) { + pin.addStates(StateConnectivityIssues) + } else { + pin.removeStates(StateConnectivityIssues) + } + + // Update Trust and Advisory Statuses. + m.updateIntelStatuses(pin, cfgOptionTrustNodeNodes()) + + // Update Statuses derived from Hub. + pin.updateStateHasRequiredInfo() + pin.updateStateActive(time.Now().Unix()) + + // 3. Update Lanes. + + // Mark all existing Lanes as inactive. + for _, lane := range pin.ConnectedTo { + lane.active = false + } + + // Update Lanes (connections to other Hubs) from the Status. + for _, lane := range pin.Hub.Status.Lanes { + // Check if this is a Lane to itself. + if lane.ID == pin.Hub.ID { + continue + } + + // First, get the Lane peer. + peer, ok := m.all[lane.ID] + if !ok { + // We need to wait for peer to be added to the Map. + continue + } + + m.updateHubLane(pin, lane, peer) + } + + // Remove all inactive/abandoned Lanes from both Pins. + var removedLanes bool + for id, lane := range pin.ConnectedTo { + if !lane.active { + // Remove Lane from this Pin. + delete(pin.ConnectedTo, id) + pin.pushChanges.Set() + removedLanes = true + // Remove Lane from peer. + peer, ok := m.all[id] + if ok { + delete(peer.ConnectedTo, pin.Hub.ID) + peer.pushChanges.Set() + } + } + } + + // Fully recalculate reachability if any Lanes were removed. + if removedLanes { + err := m.recalculateReachableHubs() + if err != nil { + log.Warningf("spn/navigator: failed to recalculate reachable Hubs: %s", err) + } + } + + // 4. Update states that depend on other information. + + // Check if hub is superseded or if it supersedes another hub. + m.updateStateSuperseded(pin) + + // Push updates. + m.PushPinChanges() +} + +const ( + minUnconfirmedLatency = 10 * time.Millisecond + maxUnconfirmedCapacity = 100000000 // 100Mbit/s + + cap1Mbit float32 = 1000000 + cap10Mbit float32 = 10000000 + cap100Mbit float32 = 100000000 + cap1Gbit float32 = 1000000000 + cap10Gbit float32 = 10000000000 +) + +// updateHubLane updates a lane between two Hubs on the Map. +// pin must already be locked, lane belongs to pin. +// peer will be locked by this function. +func (m *Map) updateHubLane(pin *Pin, lane *hub.Lane, peer *Pin) { + peer.Hub.Lock() + defer peer.Hub.Unlock() + + // Then get the corresponding Lane from that peer, if it exists. + var peerLane *hub.Lane + for _, possiblePeerLane := range peer.Hub.Status.Lanes { + if possiblePeerLane.ID == pin.Hub.ID { + peerLane = possiblePeerLane + // We have found the corresponding peerLane, break the loop. + break + } + } + if peerLane == nil { + // The peer obviously does not advertise a Lane to this Hub. + // Maybe this is a fresh Lane, and the message has not yet reached us. + // Alternatively, the Lane could have been recently removed. + + // Abandon this Lane for now. + delete(pin.ConnectedTo, peer.Hub.ID) + return + } + + // Calculate combined latency, use the greater value. + combinedLatency := lane.Latency + if peerLane.Latency > combinedLatency { + combinedLatency = peerLane.Latency + } + // Enforce minimum value if at least one side has no data. + if (lane.Latency == 0 || peerLane.Latency == 0) && combinedLatency < minUnconfirmedLatency { + combinedLatency = minUnconfirmedLatency + } + + // Calculate combined capacity, use the lesser existing value. + combinedCapacity := lane.Capacity + if combinedCapacity == 0 || (peerLane.Capacity > 0 && peerLane.Capacity < combinedCapacity) { + combinedCapacity = peerLane.Capacity + } + // Enforce maximum value if at least one side has no data. + if (lane.Capacity == 0 || peerLane.Capacity == 0) && combinedCapacity > maxUnconfirmedCapacity { + combinedCapacity = maxUnconfirmedCapacity + } + + // Calculate lane cost. + laneCost := CalculateLaneCost(combinedLatency, combinedCapacity) + + // Add Lane to both Pins and override old values in the process. + pin.ConnectedTo[peer.Hub.ID] = &Lane{ + Pin: peer, + Capacity: combinedCapacity, + Latency: combinedLatency, + Cost: laneCost, + active: true, + } + peer.ConnectedTo[pin.Hub.ID] = &Lane{ + Pin: pin, + Capacity: combinedCapacity, + Latency: combinedLatency, + Cost: laneCost, + active: true, + } + peer.pushChanges.Set() + + // Check for reachability. + + if pin.State.Has(StateReachable) { + peer.markReachable(pin.HopDistance + 1) + } + if peer.State.Has(StateReachable) { + pin.markReachable(peer.HopDistance + 1) + } +} + +// ResetFailingStates resets the failing state on all pins. +func (m *Map) ResetFailingStates(ctx context.Context) { + m.Lock() + defer m.Unlock() + + for _, pin := range m.all { + pin.ResetFailingState() + } + + m.PushPinChanges() +} + +func (m *Map) updateFailingStates(ctx context.Context, task *modules.Task) error { + m.Lock() + defer m.Unlock() + + for _, pin := range m.all { + if pin.State.Has(StateFailing) && !pin.IsFailing() { + pin.removeStates(StateFailing) + } + } + + return nil +} + +func (m *Map) updateStates(ctx context.Context, task *modules.Task) error { + var toDelete []string + + m.Lock() + defer m.Unlock() + +pinLoop: + for _, pin := range m.all { + // Check for discontinued Hubs. + if m.intel != nil { + hubIntel, ok := m.intel.Hubs[pin.Hub.ID] + if ok && hubIntel.Discontinued { + toDelete = append(toDelete, pin.Hub.ID) + log.Infof("spn/navigator: deleting discontinued %s", pin.Hub) + continue pinLoop + } + } + // Check for obsoleted Hubs. + if pin.State.HasNoneOf(StateActive) && pin.Hub.Obsolete() { + toDelete = append(toDelete, pin.Hub.ID) + log.Infof("spn/navigator: deleting obsolete %s", pin.Hub) + } + + // Delete hubs async, as deleting triggers a couple hooks that lock the map. + if len(toDelete) > 0 { + module.StartWorker("delete hubs", func(_ context.Context) error { + for _, idToDelete := range toDelete { + err := hub.RemoveHubAndMsgs(m.Name, idToDelete) + if err != nil { + log.Warningf("spn/navigator: failed to delete Hub %s: %s", idToDelete, err) + } + } + return nil + }) + } + } + + // Update StateActive. + m.updateActiveHubs() + + // Update StateReachable. + return m.recalculateReachableHubs() +} + +// AddBootstrapHubs adds the given bootstrap hubs to the map. +func (m *Map) AddBootstrapHubs(bootstrapTransports []string) error { + m.Lock() + defer m.Unlock() + + return m.addBootstrapHubs(bootstrapTransports) +} + +func (m *Map) addBootstrapHubs(bootstrapTransports []string) error { + var anyAdded bool + var lastErr error + var failed int + for _, bootstrapTransport := range bootstrapTransports { + err := m.addBootstrapHub(bootstrapTransport) + if err != nil { + log.Warningf("spn/navigator: failed to add bootstrap hub %q to map %s: %s", bootstrapTransport, m.Name, err) + lastErr = err + failed++ + } else { + anyAdded = true + } + } + + if lastErr != nil && !anyAdded { + return lastErr + } + return nil +} + +func (m *Map) addBootstrapHub(bootstrapTransport string) error { + // Parse bootstrap hub. + transport, hubID, hubIP, err := hub.ParseBootstrapHub(bootstrapTransport) + if err != nil { + return fmt.Errorf("invalid bootstrap hub: %w", err) + } + + // Check if hub already exists. + var h *hub.Hub + pin, ok := m.all[hubID] + if ok { + h = pin.Hub + } else { + h = &hub.Hub{ + ID: hubID, + Map: m.Name, + Info: &hub.Announcement{ + ID: hubID, + }, + Status: &hub.Status{}, + FirstSeen: time.Now(), // Do not garbage collect bootstrap hubs. + } + } + + // Add IP if it does not yet exist. + if hubIP4 := hubIP.To4(); hubIP4 != nil { + if h.Info.IPv4 == nil { + h.Info.IPv4 = hubIP4 + } else if !h.Info.IPv4.Equal(hubIP4) { + return fmt.Errorf("additional bootstrap entry with same ID but mismatching IP address: %s", hubIP) + } + } else { + if h.Info.IPv6 == nil { + h.Info.IPv6 = hubIP + } else if !h.Info.IPv6.Equal(hubIP) { + return fmt.Errorf("additional bootstrap entry with same ID but mismatching IP address: %s", hubIP) + } + } + + // Add transport if it does not yet exist. + t := transport.String() + if !utils.StringInSlice(h.Info.Transports, t) { + h.Info.Transports = append(h.Info.Transports, t) + } + + // Add/update to map for bootstrapping. + m.updateHub(h, false, false) + log.Infof("spn/navigator: added/updated bootstrap %s to map %s", h, m.Name) + return nil +} + +// UpdateConfigQuickSettings updates config quick settings with available countries. +func (m *Map) UpdateConfigQuickSettings(ctx context.Context) error { + ctx, tracer := log.AddTracer(ctx) + tracer.Trace("navigator: updating SPN rules country quick settings") + defer tracer.Submit() + + opts := m.DefaultOptions() + opts.Home = &HomeHubOptions{ + Regard: StateTrusted, + } + opts.Destination = &DestinationHubOptions{ + Regard: StateTrusted, + Disregard: StateIsHomeHub, + } + + // Home Policy. + if err := m.updateQuickSettingExcludeCountryList(ctx, "spn/homePolicy", opts, HomeHub); err != nil { + return err + } + // Transit Policy. + if err := m.updateQuickSettingExcludeCountryList(ctx, profile.CfgOptionTransitHubPolicyKey, opts, TransitHub); err != nil { + return err + } + // Exit Policy. + if err := m.updateSelectRuleCountryList(ctx, profile.CfgOptionExitHubPolicyKey, opts, DestinationHub); err != nil { + return err + } + // DNS Exit Policy. + if err := m.updateSelectRuleCountryList(ctx, "spn/dnsExitPolicy", opts, DestinationHub); err != nil { + return err + } + + // Trust Nodes. + if err := m.updateQuickSettingVerifiedOwnerList(ctx, "spn/trustNodes"); err != nil { + return err + } + + tracer.Trace("navigator: finished updating SPN rules country quick settings") + return nil +} + +func (m *Map) updateQuickSettingExcludeCountryList(ctx context.Context, configKey string, opts *Options, matchFor HubType) error { + // Get config option. + cfgOption, err := config.GetOption(configKey) + if err != nil { + return fmt.Errorf("failed to get config option %s: %w", configKey, err) + } + + // Get list of countries for this config option. + countries := m.GetAvailableCountries(opts, matchFor) + // Convert to list. + countryList := make([]*geoip.CountryInfo, 0, len(countries)) + for _, country := range countries { + countryList = append(countryList, country) + } + // Sort list. + slices.SortFunc[[]*geoip.CountryInfo, *geoip.CountryInfo](countryList, func(a, b *geoip.CountryInfo) int { + return strings.Compare(a.Name, b.Name) + }) + + // Compile list of quick settings. + quickSettings := make([]config.QuickSetting, 0, len(countries)) + for _, country := range countryList { + quickSettings = append(quickSettings, config.QuickSetting{ + Name: fmt.Sprintf("Exclude %s (%s)", country.Name, country.Code), + Value: []string{"- " + country.Code}, + Action: config.QuickMergeTop, + }) + } + + // Lock config option and set new quick settings. + cfgOption.Lock() + defer cfgOption.Unlock() + cfgOption.Annotations[config.QuickSettingsAnnotation] = quickSettings + + log.Tracer(ctx).Debugf("navigator: updated %d countries in quick settings for %s", len(quickSettings), configKey) + return nil +} + +type selectCountry struct { + config.QuickSetting + FlagID string +} + +func (m *Map) updateSelectRuleCountryList(ctx context.Context, configKey string, opts *Options, matchFor HubType) error { + // Get config option. + cfgOption, err := config.GetOption(configKey) + if err != nil { + return fmt.Errorf("failed to get config option %s: %w", configKey, err) + } + + // Get list of countries for this config option. + countries := m.GetAvailableCountries(opts, matchFor) + // Convert to list. + countryList := make([]*geoip.CountryInfo, 0, len(countries)) + for _, country := range countries { + countryList = append(countryList, country) + } + // Sort list. + slices.SortFunc[[]*geoip.CountryInfo, *geoip.CountryInfo](countryList, func(a, b *geoip.CountryInfo) int { + return strings.Compare(a.Name, b.Name) + }) + + // Get continents from countries. + continents := make(map[string]*geoip.ContinentInfo) + for _, country := range countryList { + continents[country.Continent.Code] = &country.Continent + } + // Convert to list. + continentList := make([]*geoip.ContinentInfo, 0, len(continents)) + for _, continent := range continents { + continentList = append(continentList, continent) + } + // Sort list. + slices.SortFunc[[]*geoip.ContinentInfo, *geoip.ContinentInfo](continentList, func(a, b *geoip.ContinentInfo) int { + return strings.Compare(a.Name, b.Name) + }) + + // Start compiling all options. + selections := make([]selectCountry, 0, len(continents)+len(countries)+2) + + // Add EU as special region. + selections = append(selections, selectCountry{ + QuickSetting: config.QuickSetting{ + Name: "European Union", + Value: []string{"+ AT", "+ BE", "+ BG", "+ CY", "+ CZ", "+ DE", "+ DK", "+ EE", "+ ES", "+ FI", "+ FR", "+ GR", "+ HR", "+ HU", "+ IE", "+ IT", "+ LT", "+ LU", "+ LV", "+ MT", "+ NL", "+ PL", "+ PT", "+ RO", "+ SE", "+ SI", "+ SK", "- *"}, + Action: config.QuickReplace, + }, + FlagID: "EU", + }) + selections = append(selections, selectCountry{ + QuickSetting: config.QuickSetting{ + Name: "US and Canada", + Value: []string{"+ US", "+ CA", "- *"}, + Action: config.QuickReplace, + }, + }) + + // Add countries to quick settings. + for _, country := range countryList { + selections = append(selections, selectCountry{ + QuickSetting: config.QuickSetting{ + Name: fmt.Sprintf("%s (%s)", country.Name, country.Code), + Value: []string{"+ " + country.Code, "- *"}, + Action: config.QuickReplace, + }, + FlagID: country.Code, + }) + } + + // Add continents to quick settings. + for _, continent := range continentList { + selections = append(selections, selectCountry{ + QuickSetting: config.QuickSetting{ + Name: fmt.Sprintf("%s (C:%s)", continent.Name, continent.Code), + Value: []string{"+ C:" + continent.Code, "- *"}, + Action: config.QuickReplace, + }, + }) + } + + // Lock config option and set new quick settings. + cfgOption.Lock() + defer cfgOption.Unlock() + cfgOption.Annotations[config.QuickSettingsAnnotation] = selections + + log.Tracer(ctx).Debugf("navigator: updated %d countries in quick settings for %s", len(selections), configKey) + return nil +} + +func (m *Map) updateQuickSettingVerifiedOwnerList(ctx context.Context, configKey string) error { + // Get config option. + cfgOption, err := config.GetOption(configKey) + if err != nil { + return fmt.Errorf("failed to get config option %s: %w", configKey, err) + } + + pins := m.pinList(true) + verifiedOwners := make([]string, 0, len(pins)/5) // Capacity is an estimation. + for _, pin := range pins { + pin.Lock() + vo := pin.VerifiedOwner + pin.Unlock() + + // Skip invalid/unneeded values. + switch vo { + case "", "Safing": + continue + } + + // Add to list, if not yet in there. + if !slices.Contains[[]string, string](verifiedOwners, vo) { + verifiedOwners = append(verifiedOwners, vo) + } + } + + // Sort list. + slices.Sort[[]string](verifiedOwners) + + // Compile list of quick settings. + quickSettings := make([]config.QuickSetting, 0, len(verifiedOwners)) + for _, vo := range verifiedOwners { + quickSettings = append(quickSettings, config.QuickSetting{ + Name: fmt.Sprintf("Trust %s", vo), + Value: []string{vo}, + Action: config.QuickMergeBottom, + }) + } + + // Lock config option and set new quick settings. + cfgOption.Lock() + defer cfgOption.Unlock() + cfgOption.Annotations[config.QuickSettingsAnnotation] = quickSettings + + log.Tracer(ctx).Debugf("navigator: updated %d verified owners in quick settings for %s", len(quickSettings), configKey) + return nil +} diff --git a/spn/patrol/domains.go b/spn/patrol/domains.go new file mode 100644 index 00000000..43fff823 --- /dev/null +++ b/spn/patrol/domains.go @@ -0,0 +1,311 @@ +package patrol + +import ( + "math/rand" + "time" +) + +// getRandomTestDomain returns a random test domain from the test domain list. +// Not cryptographically secure random, though. +func getRandomTestDomain() string { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec + return testDomains[rng.Intn(len(testDomains)-1)] //nolint:gosec // Weak randomness is not an issue here. +} + +// testDomains is a list of domains to check if they respond successfully to a HTTP GET request. +// They are sourced from tranco - trimmed, checked, and cleaned. +// Use TestCleanDomains to clean a new/updated list. +// Treat as a constant. +var testDomains = []string{ + "about.com", + "addtoany.com", + "adobe.com", + "aliyun.com", + "ampproject.org", + "android.com", + "apache.org", + "apple.com", + "apple.news", + "appspot.com", + "arnebrachhold.de", + "avast.com", + "bbc.co.uk", + "bbc.com", + "bing.com", + "blogger.com", + "blogspot.com", + "branch.io", + "calendly.com", + "cam.ac.uk", + "canonical.com", + "canva.com", + "cisco.com", + "cloudflare.com", + "cloudns.net", + "cnblogs.com", + "cnn.com", + "creativecommons.org", + "criteo.com", + "cupfox.app", + "dailymail.co.uk", + "ddnss.de", + "debian.org", + "digitalocean.com", + "doi.org", + "domainmarket.com", + "doubleclick.net", + "dreamhost.com", + "dropbox.com", + "dynect.net", + "ed.gov", + "elegantthemes.com", + "elpais.com", + "epa.gov", + "eporner.com", + "espn.com", + "europa.eu", + "example.com", + "facebook.com", + "fb.com", + "fb.me", + "fb.watch", + "fbcdn.net", + "feedburner.com", + "free.fr", + "ftc.gov", + "g.page", + "getbootstrap.com", + "gitlab.com", + "gmail.com", + "gnu.org", + "goo.gl", + "google-analytics.com", + "google.ca", + "google.co.in", + "google.co.jp", + "google.co.th", + "google.co.uk", + "google.com.au", + "google.com.br", + "google.com.hk", + "google.com.mx", + "google.com.tr", + "google.com.tw", + "google.com", + "google.de", + "google.es", + "google.fr", + "google.it", + "googledomains.com", + "googlesyndication.com", + "gstatic.com", + "harvard.edu", + "hitomi.la", + "hubspot.com", + "hugedomains.com", + "ibm.com", + "icloud.com", + "ikea.com", + "ilovepdf.com", + "indiatimes.com", + "instagram.com", + "investing.com", + "investopedia.com", + "irs.gov", + "kickstarter.com", + "launchpad.net", + "lencr.org", + "lijit.com", + "linkedin.com", + "linode.com", + "mashable.com", + "medium.com", + "mega.co.nz", + "mega.nz", + "merriam-webster.com", + "mit.edu", + "netflix.com", + "nginx.org", + "nist.gov", + "notion.so", + "nsone.net", + "office.com", + "onetrust.com", + "openstreetmap.org", + "patreon.com", + "pexels.com", + "photobucket.com", + "php.net", + "pki.goog", + "plos.org", + "ps.kz", + "readthedocs.io", + "redd.it", + "reddit.com", + "remove.bg", + "rfc-editor.org", + "savefrom.net", + "sedo.com", + "so-net.ne.jp", + "sourceforge.net", + "spamhaus.org", + "speedtest.net", + "spotify.com", + "stanford.edu", + "state.gov", + "substack.com", + "t.me", + "taboola.com", + "techcrunch.com", + "telegram.me", + "telegram.org", + "threema.ch", + "tinyurl.com", + "ubuntu.com", + "ui.com", + "umich.edu", + "uol.com.br", + "upenn.edu", + "usgs.gov", + "utexas.edu", + "va.gov", + "verisign.com", + "vmware.com", + "w3.org", + "wa.me", + "webs.com", + "whatsapp.com", + "whatsapp.net", + "whitehouse.gov", + "wikimedia.org", + "wikipedia.org", + "wiktionary.org", + "www.aliyundrive.com", + "www.amazon.ca", + "www.amazon.co.jp", + "www.amazon.co.uk", + "www.amazon.com", + "www.amazon.de", + "www.amazon.es", + "www.amazon.fr", + "www.amazon.in", + "www.amazon.it", + "www.aol.com", + "www.appsflyer.com", + "www.att.com", + "www.business.site", + "www.ca.gov", + "www.canada.ca", + "www.cctv.com", + "www.cdc.gov", + "www.chinaz.com", + "www.cloud.com", + "www.cnet.com", + "www.comcast.com", + "www.comcast.net", + "www.cornell.edu", + "www.crashlytics.com", + "www.datadoghq.com", + "www.db.com", + "www.deloitte.com", + "www.dw.com", + "www.engadget.com", + "www.eset.com", + "www.fao.org", + "www.fedex.com", + "www.flickr.com", + "www.force.com", + "www.ford.com", + "www.frontiersin.org", + "www.geeksforgeeks.org", + "www.gene.com", + "www.genius.com", + "www.github.io", + "www.gov.uk", + "www.gravatar.com", + "www.healthline.com", + "www.hhs.gov", + "www.hichina.com", + "www.hinet.net", + "www.house.gov", + "www.hp.com", + "www.huawei.com", + "www.hupu.com", + "www.ietf.org", + "www.immunet.com", + "www.independent.co.uk", + "www.intel.com", + "www.jotform.com", + "www.klaviyo.com", + "www.launchdarkly.com", + "www.live.com", + "www.macromedia.com", + "www.medallia.com", + "www.mediatek.com", + "www.medicalnewstoday.com", + "www.microsoft.com", + "www.mongodb.com", + "www.mysql.com", + "www.namu.wiki", + "www.nasa.gov", + "www.nba.com", + "www.nbcnews.com", + "www.nih.gov", + "www.noaa.gov", + "www.npr.org", + "www.nps.gov", + "www.ny.gov", + "www.okta.com", + "www.openai.com", + "www.optimizely.com", + "www.oracle.com", + "www.outlook.com", + "www.paloaltonetworks.com", + "www.pbs.org", + "www.pixabay.com", + "www.plala.or.jp", + "www.playstation.com", + "www.plesk.com", + "www.princeton.edu", + "www.prnewswire.com", + "www.psu.edu", + "www.python.org", + "www.qq.com", + "www.quantserve.com", + "www.quillbot.com", + "www.rackspace.com", + "www.redhat.com", + "www.researchgate.net", + "www.roku.com", + "www.salesforce.com", + "www.skype.com", + "www.sun.com", + "www.teamviewer.com", + "www.ted.com", + "www.tesla.com", + "www.theguardian.com", + "www.typeform.com", + "www.uchicago.edu", + "www.ucla.edu", + "www.usda.gov", + "www.usps.com", + "www.utorrent.com", + "www.warnerbros.com", + "www.webex.com", + "www.who.int", + "www.worldbank.org", + "www.xbox.com", + "www.xerox.com", + "www.youdao.com", + "www.zdnet.com", + "www.zebra.com", + "yahoo.com", + "yale.edu", + "yandex.com", + "yandex.net", + "youku.com", + "youtu.be", + "youtube.com", + "zemanta.com", + "zoro.to", +} diff --git a/spn/patrol/domains_test.go b/spn/patrol/domains_test.go new file mode 100644 index 00000000..a5e28895 --- /dev/null +++ b/spn/patrol/domains_test.go @@ -0,0 +1,67 @@ +package patrol + +import ( + "context" + "fmt" + "sort" + "testing" +) + +var enableDomainTools = "no" // change to "yes" to enable + +// TestCleanDomains checks, cleans and prints an improved domain list. +// Run with: +// go test -run ^TestCleanDomains$ github.com/safing/portmaster/spn/patrol -ldflags "-X github.com/safing/portmaster/spn/patrol.enableDomainTools=yes" -timeout 3h -v +// This is provided as a test for easier maintenance and ops. +func TestCleanDomains(t *testing.T) { //nolint:paralleltest + if enableDomainTools != "yes" { + t.Skip() + return + } + + // Setup context. + ctx := context.Background() + + // Go through all domains and check if they are reachable. + goodDomains := make([]string, 0, len(testDomains)) + for _, domain := range testDomains { + // Check if domain is reachable. + code, err := domainIsUsable(ctx, domain) + if err != nil { + fmt.Printf("FAIL: %s: %s\n", domain, err) + } else { + fmt.Printf("OK: %s [%d]\n", domain, code) + goodDomains = append(goodDomains, domain) + continue + } + + // If failed, try again with a www. prefix + wwwDomain := "www." + domain + code, err = domainIsUsable(ctx, wwwDomain) + if err != nil { + fmt.Printf("FAIL: %s: %s\n", wwwDomain, err) + } else { + fmt.Printf("OK: %s [%d]\n", wwwDomain, code) + goodDomains = append(goodDomains, wwwDomain) + } + + } + + sort.Strings(goodDomains) + fmt.Println("printing good domains:") + for _, domain := range goodDomains { + fmt.Printf("%q,\n", domain) + } + + fmt.Println("IMPORTANT: do not forget to go through list and check if everything looks good") +} + +func domainIsUsable(ctx context.Context, domain string) (statusCode int, err error) { + // Try IPv6 first as it is way more likely to fail. + statusCode, err = CheckHTTPSConnection(ctx, "tcp6", domain) + if err != nil { + return + } + + return CheckHTTPSConnection(ctx, "tcp4", domain) +} diff --git a/spn/patrol/http.go b/spn/patrol/http.go new file mode 100644 index 00000000..391518c1 --- /dev/null +++ b/spn/patrol/http.go @@ -0,0 +1,186 @@ +package patrol + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/conf" +) + +var httpsConnectivityConfirmed = abool.NewBool(true) + +// HTTPSConnectivityConfirmed returns whether the last HTTPS connectivity check succeeded. +// Is "true" before first test. +func HTTPSConnectivityConfirmed() bool { + return httpsConnectivityConfirmed.IsSet() +} + +func connectivityCheckTask(ctx context.Context, task *modules.Task) error { + // Start tracing logs. + ctx, tracer := log.AddTracer(ctx) + defer tracer.Submit() + + // Run checks and report status. + success := runConnectivityChecks(ctx) + if success { + tracer.Info("spn/patrol: all connectivity checks succeeded") + if httpsConnectivityConfirmed.SetToIf(false, true) { + module.TriggerEvent(ChangeSignalEventName, nil) + } + return nil + } + + tracer.Errorf("spn/patrol: connectivity check failed") + if httpsConnectivityConfirmed.SetToIf(true, false) { + module.TriggerEvent(ChangeSignalEventName, nil) + } + return nil +} + +func runConnectivityChecks(ctx context.Context) (ok bool) { + switch { + case conf.HubHasIPv4() && !runHTTPSConnectivityChecks(ctx, "tcp4"): + return false + case conf.HubHasIPv6() && !runHTTPSConnectivityChecks(ctx, "tcp6"): + return false + default: + // All checks passed. + return true + } +} + +func runHTTPSConnectivityChecks(ctx context.Context, network string) (ok bool) { + // Step 1: Check 1 domain, require 100% + if checkHTTPSConnectivity(ctx, network, 1, 1) { + return true + } + + // Step 2: Check 5 domains, require 80% + if checkHTTPSConnectivity(ctx, network, 5, 0.8) { + return true + } + + // Step 3: Check 20 domains, require 70% + if checkHTTPSConnectivity(ctx, network, 20, 0.7) { + return true + } + + return false +} + +func checkHTTPSConnectivity(ctx context.Context, network string, checks int, requiredSuccessFraction float32) (ok bool) { + log.Tracer(ctx).Tracef( + "spn/patrol: testing connectivity via https (%d checks; %.0f%% required)", + checks, + requiredSuccessFraction*100, + ) + + // Run tests. + var succeeded int + for i := 0; i < checks; i++ { + if checkHTTPSConnection(ctx, network) { + succeeded++ + } + } + + // Check success. + successFraction := float32(succeeded) / float32(checks) + if successFraction < requiredSuccessFraction { + log.Tracer(ctx).Warningf( + "spn/patrol: https/%s connectivity check failed: %d/%d (%.0f%%)", + network, + succeeded, + checks, + successFraction*100, + ) + return false + } + + log.Tracer(ctx).Debugf( + "spn/patrol: https/%s connectivity check succeeded: %d/%d (%.0f%%)", + network, + succeeded, + checks, + successFraction*100, + ) + return true +} + +func checkHTTPSConnection(ctx context.Context, network string) (ok bool) { + testDomain := getRandomTestDomain() + code, err := CheckHTTPSConnection(ctx, network, testDomain) + if err != nil { + log.Tracer(ctx).Debugf("spn/patrol: https/%s connect check failed: %s: %s", network, testDomain, err) + return false + } + + log.Tracer(ctx).Tracef("spn/patrol: https/%s connect check succeeded: %s [%d]", network, testDomain, code) + return true +} + +// CheckHTTPSConnection checks if a HTTPS connection to the given domain can be established. +func CheckHTTPSConnection(ctx context.Context, network, domain string) (statusCode int, err error) { + // Check network parameter. + switch network { + case "tcp4": + case "tcp6": + default: + return 0, fmt.Errorf("provided unsupported network: %s", network) + } + + // Build URL. + // Use HTTPS to ensure that we have really communicated with the desired + // server and not with an intermediate. + url := fmt.Sprintf("https://%s/", domain) + + // Prepare all parts of the request. + // TODO: Evaluate if we want to change the User-Agent. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, err + } + dialer := &net.Dialer{ + Timeout: 15 * time.Second, + LocalAddr: conf.GetBindAddr(network), + FallbackDelay: -1, // Disables Fast Fallback from IPv6 to IPv4. + KeepAlive: -1, // Disable keep-alive. + } + dialWithNet := func(ctx context.Context, _, addr string) (net.Conn, error) { + // Ignore network by http client. + // Instead, force either tcp4 or tcp6. + return dialer.DialContext(ctx, network, addr) + } + client := &http.Client{ + Transport: &http.Transport{ + DialContext: dialWithNet, + DisableKeepAlives: true, + DisableCompression: true, + TLSHandshakeTimeout: 15 * time.Second, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: 30 * time.Second, + } + + // Make request to server. + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to send http request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return resp.StatusCode, fmt.Errorf("unexpected status code: %s", resp.Status) + } + + return resp.StatusCode, nil +} diff --git a/spn/patrol/module.go b/spn/patrol/module.go new file mode 100644 index 00000000..842c139c --- /dev/null +++ b/spn/patrol/module.go @@ -0,0 +1,32 @@ +package patrol + +import ( + "time" + + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/conf" +) + +// ChangeSignalEventName is the name of the event that signals any change in the patrol system. +const ChangeSignalEventName = "change signal" + +var module *modules.Module + +func init() { + module = modules.Register("patrol", prep, start, nil, "rng") +} + +func prep() error { + module.RegisterEvent(ChangeSignalEventName, false) + + return nil +} + +func start() error { + if conf.PublicHub() { + module.NewTask("connectivity test", connectivityCheckTask). + Repeat(5 * time.Minute) + } + + return nil +} diff --git a/spn/ships/connection_test.go b/spn/ships/connection_test.go new file mode 100644 index 00000000..5d03927b --- /dev/null +++ b/spn/ships/connection_test.go @@ -0,0 +1,131 @@ +package ships + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safing/portmaster/spn/hub" +) + +var ( + testPort uint16 = 65000 + testData = []byte("The quick brown fox jumps over the lazy dog") + localhost = net.IPv4(127, 0, 0, 1) +) + +func getTestPort() uint16 { + testPort++ + return testPort +} + +func getTestBuf() []byte { + return make([]byte, len(testData)) +} + +func TestConnections(t *testing.T) { + t.Parallel() + + registryLock.Lock() + t.Cleanup(func() { + registryLock.Unlock() + }) + + for k, v := range registry { //nolint:paralleltest // False positive. + protocol, builder := k, v + t.Run(protocol, func(t *testing.T) { + t.Parallel() + + var wg sync.WaitGroup + ctx, cancelCtx := context.WithCancel(context.Background()) + + // docking requests + dockingRequests := make(chan Ship, 1) + transport := &hub.Transport{ + Protocol: protocol, + Port: getTestPort(), + } + + // create listener + pier, err := builder.EstablishPier(transport, dockingRequests) + if err != nil { + t.Fatal(err) + } + + // connect to listener + ship, err := builder.LaunchShip(ctx, transport, localhost) + if err != nil { + t.Fatal(err) + } + + // client send + err = ship.Load(testData) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // dock client + srvShip := <-dockingRequests + if srvShip == nil { + t.Fatalf("%s failed to dock", pier) + } + + // server recv + buf := getTestBuf() + _, err = srvShip.UnloadTo(buf) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // check data + assert.Equal(t, testData, buf, "should match") + fmt.Print(".") + + for i := 0; i < 100; i++ { + // server send + err = srvShip.Load(testData) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // client recv + buf = getTestBuf() + _, err = ship.UnloadTo(buf) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // check data + assert.Equal(t, testData, buf, "should match") + fmt.Print(".") + + // client send + err = ship.Load(testData) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // server recv + buf = getTestBuf() + _, err = srvShip.UnloadTo(buf) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // check data + assert.Equal(t, testData, buf, "should match") + fmt.Print(".") + } + + ship.Sink() + srvShip.Sink() + pier.Abolish() + cancelCtx() + wg.Wait() // wait for docking procedure to end + }) + } +} diff --git a/spn/ships/http.go b/spn/ships/http.go new file mode 100644 index 00000000..165ca9df --- /dev/null +++ b/spn/ships/http.go @@ -0,0 +1,230 @@ +package ships + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" +) + +// HTTPShip is a ship that uses HTTP. +type HTTPShip struct { + ShipBase +} + +// HTTPPier is a pier that uses HTTP. +type HTTPPier struct { + PierBase + + newDockings chan net.Conn +} + +func init() { + Register("http", &Builder{ + LaunchShip: launchHTTPShip, + EstablishPier: establishHTTPPier, + }) +} + +/* +HTTP Transport Variants: + +1. Hijack connection and switch to raw SPN protocol: + +Request: + + GET HTTP/1.1 + Connection: Upgrade + Upgrade: SPN + +Response: + + HTTP/1.1 101 Switching Protocols + Connection: Upgrade + Upgrade: SPN + +*/ + +func launchHTTPShip(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error) { + // Default to root path. + path := transport.Path + if path == "" { + path = "/" + } + + // Build request for Variant 1. + variant := 1 + request, err := http.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to build HTTP request: %w", err) + } + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "SPN") + + // Create connection. + var dialNet string + if ip4 := ip.To4(); ip4 != nil { + dialNet = "tcp4" + } else { + dialNet = "tcp6" + } + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + LocalAddr: conf.GetBindAddr(dialNet), + FallbackDelay: -1, // Disables Fast Fallback from IPv6 to IPv4. + KeepAlive: -1, // Disable keep-alive. + } + conn, err := dialer.DialContext(ctx, dialNet, net.JoinHostPort(ip.String(), portToA(transport.Port))) + if err != nil { + return nil, fmt.Errorf("failed to connect: %w", err) + } + + // Send HTTP request. + err = request.Write(conn) + if err != nil { + return nil, fmt.Errorf("failed to send HTTP request: %w", err) + } + + // Receive HTTP response. + response, err := http.ReadResponse(bufio.NewReader(conn), request) + if err != nil { + return nil, fmt.Errorf("failed to read HTTP response: %w", err) + } + defer response.Body.Close() //nolint:errcheck,gosec + + // Handle response according to variant. + switch variant { + case 1: + if response.StatusCode == http.StatusSwitchingProtocols && + response.Header.Get("Connection") == "Upgrade" && + response.Header.Get("Upgrade") == "SPN" { + // Continue + } else { + return nil, fmt.Errorf("received unexpected response for variant 1: %s", response.Status) + } + + default: + return nil, fmt.Errorf("internal error: unsupported http transport variant: %d", variant) + } + + // Create ship. + ship := &HTTPShip{ + ShipBase: ShipBase{ + conn: conn, + transport: transport, + mine: true, + secure: false, + }, + } + + // Init and return. + ship.calculateLoadSize(ip, nil, TCPHeaderMTUSize) + ship.initBase() + return ship, nil +} + +func (pier *HTTPPier) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && + r.Header.Get("Connection") == "Upgrade" && + r.Header.Get("Upgrade") == "SPN": + // Request for Variant 1. + + // Hijack connection. + var conn net.Conn + if hijacker, ok := w.(http.Hijacker); ok { + // Empty body, so the hijacked connection starts with a clean buffer. + _, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "", http.StatusInternalServerError) + log.Warningf("ships: failed to empty body for hijack for %s: %s", r.RemoteAddr, err) + return + } + _ = r.Body.Close() + + // Reply with upgrade confirmation. + w.Header().Set("Connection", "Upgrade") + w.Header().Set("Upgrade", "SPN") + w.WriteHeader(http.StatusSwitchingProtocols) + + // Get connection. + conn, _, err = hijacker.Hijack() + if err != nil { + log.Warningf("ships: failed to hijack http connection from %s: %s", r.RemoteAddr, err) + return + } + } else { + http.Error(w, "", http.StatusInternalServerError) + log.Warningf("ships: connection from %s cannot be hijacked", r.RemoteAddr) + return + } + + // Create new ship. + ship := &HTTPShip{ + ShipBase: ShipBase{ + transport: pier.transport, + conn: conn, + mine: false, + secure: false, + }, + } + ship.calculateLoadSize(nil, conn.RemoteAddr(), TCPHeaderMTUSize) + ship.initBase() + + // Submit new docking request. + select { + case pier.dockingRequests <- ship: + case <-r.Context().Done(): + return + } + + default: + // Reply with info page if no variant matches the request. + ServeInfoPage(w, r) + } +} + +func establishHTTPPier(transport *hub.Transport, dockingRequests chan Ship) (Pier, error) { + // Default to root path. + path := transport.Path + if path == "" { + path = "/" + } + + // Create pier. + pier := &HTTPPier{ + newDockings: make(chan net.Conn), + PierBase: PierBase{ + transport: transport, + dockingRequests: dockingRequests, + }, + } + pier.initBase() + + // Register handler. + err := addHTTPHandler(transport.Port, path, pier.ServeHTTP) + if err != nil { + return nil, fmt.Errorf("failed to add HTTP handler: %w", err) + } + + return pier, nil +} + +// Abolish closes the underlying listener and cleans up any related resources. +func (pier *HTTPPier) Abolish() { + // Only abolish once. + if !pier.abolishing.SetToIf(false, true) { + return + } + + // Do not close the listener, as it is shared. + // Instead, remove the HTTP handler and the shared server will shutdown itself when needed. + _ = removeHTTPHandler(pier.transport.Port, pier.transport.Path) +} diff --git a/spn/ships/http_info.go b/spn/ships/http_info.go new file mode 100644 index 00000000..886f2127 --- /dev/null +++ b/spn/ships/http_info.go @@ -0,0 +1,83 @@ +package ships + +import ( + "bytes" + _ "embed" + "html/template" + "net/http" + + "github.com/safing/portbase/config" + "github.com/safing/portbase/info" + "github.com/safing/portbase/log" +) + +var ( + //go:embed http_info_page.html.tmpl + infoPageData string + + infoPageTemplate *template.Template + + // DisplayHubID holds the Hub ID for displaying it on the info page. + DisplayHubID string +) + +type infoPageInput struct { + Version string + Info *info.Info + ID string + Name string + Group string + ContactAddress string + ContactService string +} + +var ( + pageInputName config.StringOption + pageInputGroup config.StringOption + pageInputContactAddress config.StringOption + pageInputContactService config.StringOption +) + +func initPageInput() { + infoPageTemplate = template.Must(template.New("info-page").Parse(infoPageData)) + + pageInputName = config.Concurrent.GetAsString("spn/publicHub/name", "") + pageInputGroup = config.Concurrent.GetAsString("spn/publicHub/group", "") + pageInputContactAddress = config.Concurrent.GetAsString("spn/publicHub/contactAddress", "") + pageInputContactService = config.Concurrent.GetAsString("spn/publicHub/contactService", "") +} + +// ServeInfoPage serves the info page for the given request. +func ServeInfoPage(w http.ResponseWriter, r *http.Request) { + pageData, err := renderInfoPage() + if err != nil { + log.Warningf("ships: failed to render SPN info page: %s", err) + http.Error(w, "", http.StatusInternalServerError) + return + } + + _, err = w.Write(pageData) + if err != nil { + log.Warningf("ships: failed to write info page: %s", err) + } +} + +func renderInfoPage() ([]byte, error) { + input := &infoPageInput{ + Version: info.Version(), + Info: info.GetInfo(), + ID: DisplayHubID, + Name: pageInputName(), + Group: pageInputGroup(), + ContactAddress: pageInputContactAddress(), + ContactService: pageInputContactService(), + } + + buf := &bytes.Buffer{} + err := infoPageTemplate.ExecuteTemplate(buf, "info-page", input) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/spn/ships/http_info_page.html.tmpl b/spn/ships/http_info_page.html.tmpl new file mode 100644 index 00000000..dff0edbc --- /dev/null +++ b/spn/ships/http_info_page.html.tmpl @@ -0,0 +1,111 @@ + + + + + + SPN Node + + + +
+
+

+ You Have Reached an SPN Node +

+

+ The server, or at least the exact URL you have accessed, leads to an SPN Node. +

+
+ +
+

+ What is SPN? +

+

+ SPN stands for "Safing Privacy Network" and is a network of servers that offers high privacy protection of Internet traffic and activity. It was built to replace VPNs for their Internet privacy use case. +

+
+ +
+

+ More Information +

+

+ You can find out more about SPN here: +

+

+
+ +
+

+ Contact the Operator of This SPN Node +

+

+ {{ if .ContactAddress }} + You can reach the operator of this SPN Node here: + {{ .ContactAddress }} + {{ if .ContactService }} via {{ .ContactService }} + {{ end }} + {{ else }} + The operator of this SPN Node has not configured any contact data.
+ Please contact the operator using the usual methods via the hosting provider. + {{ end }} +

+
+ +
+

+ Are You Tracing Bad Activity? +

+

+ We are sorry there is an incident involving this server. We condemn any disruptive or illegal activity. +

+

+ Please note that servers are not only operated by Safing (the company behind SPN), but also by third parties. +

+

+ The SPN works very similar to Tor. Its primary goal is to provide people more privacy on the Internet. We also provide our services to people behind censoring firewalls in oppressive regimes. +

+

+ This server does not host any content (as part of its role in the SPN network). Rather, it is part of the network where nodes on the Internet simply pass packets among themselves before sending them to their destinations, just as any Internet intermediary does. +

+

+ Please understand that the SPN makes it technically impossible to single out individual users. We are also legally bound to respective privacy rights. +

+

+ We can offer to block specific destination IPs and ports, but the abuser doesn't use this server specifically; instead, they will just be routed through a different exit node outside of our control. +

+
+ +
+

+ SPN Node Info +

+

+

    +
  • Name: {{ .Name }}
  • +
  • Group: {{ .Group }}
  • +
  • ContactAddress: {{ .ContactAddress }}
  • +
  • ContactService: {{ .ContactService }}
  • +
  • Version: {{ .Version }}
  • +
  • ID: {{ .ID }}
  • +
  • + Build: +
      +
    • Commit: {{ .Info.Commit }}
    • +
    • At: {{ .Info.CommitTime }}
    • +
    • From: {{ .Info.Source }}
    • +
    +
  • +
+

+
+
+ + diff --git a/spn/ships/http_info_test.go b/spn/ships/http_info_test.go new file mode 100644 index 00000000..a490dfce --- /dev/null +++ b/spn/ships/http_info_test.go @@ -0,0 +1,26 @@ +package ships + +import ( + "html/template" + "testing" + + "github.com/safing/portbase/config" +) + +func TestInfoPageTemplate(t *testing.T) { + t.Parallel() + + infoPageTemplate = template.Must(template.New("info-page").Parse(infoPageData)) + pageInputName = config.Concurrent.GetAsString("spn/publicHub/name", "node-name") + pageInputGroup = config.Concurrent.GetAsString("spn/publicHub/group", "node-group") + pageInputContactAddress = config.Concurrent.GetAsString("spn/publicHub/contactAddress", "john@doe.com") + pageInputContactService = config.Concurrent.GetAsString("spn/publicHub/contactService", "email") + + pageData, err := renderInfoPage() + if err != nil { + t.Fatal(err) + } + + _ = pageData + // t.Log(string(pageData)) +} diff --git a/spn/ships/http_shared.go b/spn/ships/http_shared.go new file mode 100644 index 00000000..c90504e1 --- /dev/null +++ b/spn/ships/http_shared.go @@ -0,0 +1,188 @@ +package ships + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" +) + +type sharedServer struct { + server *http.Server + + handlers map[string]http.HandlerFunc + handlersLock sync.RWMutex +} + +// ServeHTTP forwards requests to registered handler or uses defaults. +func (shared *sharedServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + shared.handlersLock.Lock() + defer shared.handlersLock.Unlock() + + // Get and forward to registered handler. + handler, ok := shared.handlers[r.URL.Path] + if ok { + handler(w, r) + return + } + + // If there is registered handler and path is "/", respond with info page. + if r.Method == http.MethodGet && r.URL.Path == "/" { + ServeInfoPage(w, r) + return + } + + // Otherwise, respond with error. + http.Error(w, "", http.StatusNotFound) +} + +var ( + sharedHTTPServers = make(map[uint16]*sharedServer) + sharedHTTPServersLock sync.Mutex +) + +func addHTTPHandler(port uint16, path string, handler http.HandlerFunc) error { + // Check params. + if port == 0 { + return errors.New("cannot listen on port 0") + } + + // Default to root path. + if path == "" { + path = "/" + } + + sharedHTTPServersLock.Lock() + defer sharedHTTPServersLock.Unlock() + + // Get http server of the port. + shared, ok := sharedHTTPServers[port] + if ok { + // Set path to handler. + shared.handlersLock.Lock() + defer shared.handlersLock.Unlock() + + // Check if path is already registered. + _, ok := shared.handlers[path] + if ok { + return errors.New("path already registered") + } + + // Else, register handler at path. + shared.handlers[path] = handler + return nil + } + + // Shared server does not exist - create one. + shared = &sharedServer{ + handlers: make(map[string]http.HandlerFunc), + } + + // Add first handler. + shared.handlers[path] = handler + + // Define new server. + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: shared, + ReadTimeout: 1 * time.Minute, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 1 * time.Minute, + IdleTimeout: 1 * time.Minute, + MaxHeaderBytes: 4096, + // ErrorLog: &log.Logger{}, // FIXME + BaseContext: func(net.Listener) context.Context { return module.Ctx }, + } + shared.server = server + + // Start listeners. + bindIPs := conf.GetBindIPs() + listeners := make([]net.Listener, 0, len(bindIPs)) + for _, bindIP := range bindIPs { + listener, err := net.ListenTCP("tcp", &net.TCPAddr{ + IP: bindIP, + Port: int(port), + }) + if err != nil { + return fmt.Errorf("failed to listen: %w", err) + } + + listeners = append(listeners, listener) + log.Infof("spn/ships: http transport pier established on %s", listener.Addr()) + } + + // Add shared http server to list. + sharedHTTPServers[port] = shared + + // Start servers in service workers. + for _, listener := range listeners { + serviceListener := listener + module.StartServiceWorker( + fmt.Sprintf("shared http server listener on %s", listener.Addr()), 0, + func(ctx context.Context) error { + err := shared.server.Serve(serviceListener) + if !errors.Is(http.ErrServerClosed, err) { + return err + } + return nil + }, + ) + } + + return nil +} + +func removeHTTPHandler(port uint16, path string) error { + // Check params. + if port == 0 { + return nil + } + + // Default to root path. + if path == "" { + path = "/" + } + + sharedHTTPServersLock.Lock() + defer sharedHTTPServersLock.Unlock() + + // Get http server of the port. + shared, ok := sharedHTTPServers[port] + if !ok { + return nil + } + + // Set path to handler. + shared.handlersLock.Lock() + defer shared.handlersLock.Unlock() + + // Check if path is registered. + _, ok = shared.handlers[path] + if !ok { + return nil + } + + // Remove path from handler. + delete(shared.handlers, path) + + // Shutdown shared HTTP server if no more handlers are registered. + if len(shared.handlers) == 0 { + ctx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + defer cancel() + return shared.server.Shutdown(ctx) + } + + // Remove shared HTTP server from map. + delete(sharedHTTPServers, port) + + return nil +} diff --git a/spn/ships/http_shared_test.go b/spn/ships/http_shared_test.go new file mode 100644 index 00000000..d48417e4 --- /dev/null +++ b/spn/ships/http_shared_test.go @@ -0,0 +1,34 @@ +package ships + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSharedHTTP(t *testing.T) { //nolint:paralleltest // Test checks global state. + const testPort = 65100 + + // Register multiple handlers. + err := addHTTPHandler(testPort, "", ServeInfoPage) + require.NoError(t, err, "should be able to share http listener") + err = addHTTPHandler(testPort, "/test", ServeInfoPage) + require.NoError(t, err, "should be able to share http listener") + err = addHTTPHandler(testPort, "/test2", ServeInfoPage) + require.NoError(t, err, "should be able to share http listener") + err = addHTTPHandler(testPort, "/", ServeInfoPage) + require.Error(t, err, "should fail to register path twice") + + // Unregister + require.NoError(t, removeHTTPHandler(testPort, "")) + require.NoError(t, removeHTTPHandler(testPort, "/test")) + require.NoError(t, removeHTTPHandler(testPort, "/not-registered")) // removing unregistered handler does not error + require.NoError(t, removeHTTPHandler(testPort, "/test2")) + require.NoError(t, removeHTTPHandler(testPort, "/not-registered")) // removing unregistered handler does not error + + // Check if all handlers are gone again. + sharedHTTPServersLock.Lock() + defer sharedHTTPServersLock.Unlock() + assert.Empty(t, sharedHTTPServers, "shared http handlers should be back to zero") +} diff --git a/spn/ships/kcp.go b/spn/ships/kcp.go new file mode 100644 index 00000000..88bfb2ad --- /dev/null +++ b/spn/ships/kcp.go @@ -0,0 +1,81 @@ +package ships + +// KCPShip is a ship that uses KCP. +type KCPShip struct { + ShipBase +} + +// KCPPier is a pier that uses KCP. +type KCPPier struct { + PierBase +} + +// TODO: Find a replacement for kcp, which turned out to not fit our use case. +/* +func init() { + Register("kcp", &Builder{ + LaunchShip: launchKCPShip, + EstablishPier: establishKCPPier, + }) +} + +func launchKCPShip(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error) { + conn, err := kcp.Dial(net.JoinHostPort(ip.String(), portToA(transport.Port))) + if err != nil { + return nil, err + } + + ship := &KCPShip{ + ShipBase: ShipBase{ + conn: conn, + transport: transport, + mine: true, + secure: false, + // Calculate KCP's MSS. + loadSize: kcp.IKCP_MTU_DEF - kcp.IKCP_OVERHEAD, + }, + } + + ship.initBase() + return ship, nil +} + +func establishKCPPier(transport *hub.Transport, dockingRequests chan *DockingRequest) (Pier, error) { + listener, err := kcp.Listen(net.JoinHostPort("", portToA(transport.Port))) + if err != nil { + return nil, err + } + + pier := &KCPPier{ + PierBase: PierBase{ + transport: transport, + listener: listener, + dockingRequests: dockingRequests, + }, + } + pier.PierBase.dockShip = pier.dockShip + pier.initBase() + return pier, nil +} + +func (pier *KCPPier) dockShip() (Ship, error) { + conn, err := pier.listener.Accept() + if err != nil { + return nil, err + } + + ship := &KCPShip{ + ShipBase: ShipBase{ + conn: conn, + transport: pier.transport, + mine: false, + secure: false, + // Calculate KCP's MSS. + loadSize: kcp.IKCP_MTU_DEF - kcp.IKCP_OVERHEAD, + }, + } + + ship.initBase() + return ship, nil +} +*/ diff --git a/spn/ships/launch.go b/spn/ships/launch.go new file mode 100644 index 00000000..45a77834 --- /dev/null +++ b/spn/ships/launch.go @@ -0,0 +1,114 @@ +package ships + +import ( + "context" + "fmt" + "net" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/spn/hub" +) + +// Launch launches a new ship to the given Hub. +func Launch(ctx context.Context, h *hub.Hub, transport *hub.Transport, ip net.IP) (Ship, error) { + var transports []*hub.Transport + var ips []net.IP + + // choose transports + if transport != nil { + transports = []*hub.Transport{transport} + } else { + if h.Info == nil { + return nil, hub.ErrMissingInfo + } + transports = h.Info.ParsedTransports() + // If there are no transports, check if they were parsed. + if len(transports) == 0 && len(h.Info.Transports) > 0 { + log.Errorf("ships: %s has no parsed transports, but transports are %v", h, h.Info.Transports) + // Attempt to parse transports now. + transports, _ = hub.ParseTransports(h.Info.Transports) + } + // Fail if there are not transports. + if len(transports) == 0 { + return nil, hub.ErrMissingTransports + } + } + + // choose IPs + if ip != nil { + ips = []net.IP{ip} + } else { + if h.Info == nil { + return nil, hub.ErrMissingInfo + } + ips = make([]net.IP, 0, 3) + // If IPs have been verified, check if we can use a virtual network address. + var vnetForced bool + if h.VerifiedIPs { + vnet := GetVirtualNetworkConfig() + if vnet != nil { + virtIP := vnet.Mapping[h.ID] + if virtIP != nil { + ips = append(ips, virtIP) + if vnet.Force { + vnetForced = true + log.Infof("spn/ships: forcing virtual network address %s for %s", virtIP, h) + } else { + log.Infof("spn/ships: using virtual network address %s for %s", virtIP, h) + } + } + } + } + // Add Hub's IPs if no virtual address was forced. + if !vnetForced { + // prioritize IPv4 + if h.Info.IPv4 != nil { + ips = append(ips, h.Info.IPv4) + } + if h.Info.IPv6 != nil && netenv.IPv6Enabled() { + ips = append(ips, h.Info.IPv6) + } + } + if len(ips) == 0 { + return nil, hub.ErrMissingIPs + } + } + + // connect + var firstErr error + for _, ip := range ips { + for _, tr := range transports { + ship, err := connectTo(ctx, h, tr, ip) + if err == nil { + return ship, nil // return on success + } + + // Check if context is canceled. + if ctx.Err() != nil { + return nil, ctx.Err() + } + + // Save first error. + if firstErr == nil { + firstErr = err + } + } + } + + return nil, firstErr +} + +func connectTo(ctx context.Context, h *hub.Hub, transport *hub.Transport, ip net.IP) (Ship, error) { + builder := GetBuilder(transport.Protocol) + if builder == nil { + return nil, fmt.Errorf("protocol %s not supported", transport.Protocol) + } + + ship, err := builder.LaunchShip(ctx, transport, ip) + if err != nil { + return nil, fmt.Errorf("failed to connect to %s using %s (%s): %w", h, transport, ip, err) + } + + return ship, nil +} diff --git a/spn/ships/masking.go b/spn/ships/masking.go new file mode 100644 index 00000000..76d9fc37 --- /dev/null +++ b/spn/ships/masking.go @@ -0,0 +1,63 @@ +package ships + +import ( + "crypto/sha1" + "net" + + "github.com/mr-tron/base58" + "github.com/tevino/abool" +) + +var ( + maskingEnabled = abool.New() + maskingActive = abool.New() + maskingBytes []byte +) + +// EnableMasking enables masking with the given salt. +func EnableMasking(salt []byte) { + if maskingEnabled.SetToIf(false, true) { + maskingBytes = salt + maskingActive.Set() + } +} + +// MaskAddress masks the given address if masking is enabled and the ship is +// not public. +func (ship *ShipBase) MaskAddress(addr net.Addr) string { + // Return in plain if masking is not enabled or if ship is public. + if maskingActive.IsNotSet() || ship.Public() { + return addr.String() + } + + switch typedAddr := addr.(type) { + case *net.TCPAddr: + return ship.MaskIP(typedAddr.IP) + case *net.UDPAddr: + return ship.MaskIP(typedAddr.IP) + default: + return ship.Mask([]byte(addr.String())) + } +} + +// MaskIP masks the given IP if masking is enabled and the ship is not public. +func (ship *ShipBase) MaskIP(ip net.IP) string { + // Return in plain if masking is not enabled or if ship is public. + if maskingActive.IsNotSet() || ship.Public() { + return ip.String() + } + + return ship.Mask(ip) +} + +// Mask masks the given value. +func (ship *ShipBase) Mask(value []byte) string { + // Hash the IP with masking bytes. + hasher := sha1.New() //nolint:gosec // Not used for cryptography. + hasher.Write(maskingBytes) + hasher.Write(value) + masked := hasher.Sum(nil) + + // Return first 8 characters from the base58-encoded hash. + return "masked:" + base58.Encode(masked)[:8] +} diff --git a/spn/ships/module.go b/spn/ships/module.go new file mode 100644 index 00000000..d450185e --- /dev/null +++ b/spn/ships/module.go @@ -0,0 +1,20 @@ +package ships + +import ( + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/spn/conf" +) + +var module *modules.Module + +func init() { + module = modules.Register("ships", start, nil, nil, "cabin") +} + +func start() error { + if conf.PublicHub() { + initPageInput() + } + + return nil +} diff --git a/spn/ships/mtu.go b/spn/ships/mtu.go new file mode 100644 index 00000000..07bb1a14 --- /dev/null +++ b/spn/ships/mtu.go @@ -0,0 +1,47 @@ +package ships + +import "net" + +// MTU Calculation Configuration. +const ( + BaseMTU = 1460 // 1500 with 40 bytes extra space for special cases. + IPv4HeaderMTUSize = 20 // Without options, as not common. + IPv6HeaderMTUSize = 40 // Without options, as not common. + TCPHeaderMTUSize = 60 // Maximum size with options. + UDPHeaderMTUSize = 8 // Has no options. +) + +func (ship *ShipBase) calculateLoadSize(ip net.IP, addr net.Addr, subtract ...int) { + ship.loadSize = BaseMTU + + // Convert addr to IP if needed. + if ip == nil && addr != nil { + switch v := addr.(type) { + case *net.TCPAddr: + ip = v.IP + case *net.UDPAddr: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + } + + // Subtract IP Header, if IP is available. + if ip != nil { + if ip4 := ip.To4(); ip4 != nil { + ship.loadSize -= IPv4HeaderMTUSize + } else { + ship.loadSize -= IPv6HeaderMTUSize + } + } + + // Subtract others. + for sub := range subtract { + ship.loadSize -= sub + } + + // Raise buf size to at least load size. + if ship.bufSize < ship.loadSize { + ship.bufSize = ship.loadSize + } +} diff --git a/spn/ships/pier.go b/spn/ships/pier.go new file mode 100644 index 00000000..78483bf4 --- /dev/null +++ b/spn/ships/pier.go @@ -0,0 +1,82 @@ +package ships + +import ( + "fmt" + "net" + + "github.com/tevino/abool" + + "github.com/safing/portmaster/spn/hub" +) + +// Pier represents a network connection listener. +type Pier interface { + // String returns a human readable informational summary about the ship. + String() string + + // Transport returns the transport used for this ship. + Transport() *hub.Transport + + // Abolish closes the underlying listener and cleans up any related resources. + Abolish() +} + +// DockingRequest is a uniform request that Piers emit when a new ship arrives. +type DockingRequest struct { + Pier Pier + Ship Ship + Err error +} + +// EstablishPier is shorthand function to get the transport's builder and establish a pier. +func EstablishPier(transport *hub.Transport, dockingRequests chan Ship) (Pier, error) { + builder := GetBuilder(transport.Protocol) + if builder == nil { + return nil, fmt.Errorf("protocol %s not supported", transport.Protocol) + } + + pier, err := builder.EstablishPier(transport, dockingRequests) + if err != nil { + return nil, fmt.Errorf("failed to establish pier on %s: %w", transport, err) + } + + return pier, nil +} + +// PierBase implements common functions to comply with the Pier interface. +type PierBase struct { + // transport holds the transport definition of the pier. + transport *hub.Transport + // listeners holds the actual underlying listeners. + listeners []net.Listener + + // dockingRequests is used to report new connections to the higher layer. + dockingRequests chan Ship + + // abolishing specifies if the pier and listener is being closed. + abolishing *abool.AtomicBool +} + +func (pier *PierBase) initBase() { + // init + pier.abolishing = abool.New() +} + +// String returns a human readable informational summary about the ship. +func (pier *PierBase) String() string { + return fmt.Sprintf("", pier.transport) +} + +// Transport returns the transport used for this ship. +func (pier *PierBase) Transport() *hub.Transport { + return pier.transport +} + +// Abolish closes the underlying listener and cleans up any related resources. +func (pier *PierBase) Abolish() { + if pier.abolishing.SetToIf(false, true) { + for _, listener := range pier.listeners { + _ = listener.Close() + } + } +} diff --git a/spn/ships/registry.go b/spn/ships/registry.go new file mode 100644 index 00000000..5d3abba7 --- /dev/null +++ b/spn/ships/registry.go @@ -0,0 +1,55 @@ +package ships + +import ( + "context" + "net" + "strconv" + "sync" + + "github.com/safing/portmaster/spn/hub" +) + +// Builder is a factory that can build ships and piers of it's protocol. +type Builder struct { + LaunchShip func(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error) + EstablishPier func(transport *hub.Transport, dockingRequests chan Ship) (Pier, error) +} + +var ( + registry = make(map[string]*Builder) + allProtocols []string + registryLock sync.Mutex +) + +// Register registers a new builder for a protocol. +func Register(protocol string, builder *Builder) { + registryLock.Lock() + defer registryLock.Unlock() + + registry[protocol] = builder +} + +// GetBuilder returns the builder for the given protocol, or nil if it does not exist. +func GetBuilder(protocol string) *Builder { + registryLock.Lock() + defer registryLock.Unlock() + + builder, ok := registry[protocol] + if !ok { + return nil + } + return builder +} + +// Protocols returns a slice with all registered protocol names. The return slice must not be edited. +func Protocols() []string { + registryLock.Lock() + defer registryLock.Unlock() + + return allProtocols +} + +// portToA transforms the given port into a string. +func portToA(port uint16) string { + return strconv.FormatUint(uint64(port), 10) +} diff --git a/spn/ships/ship.go b/spn/ships/ship.go new file mode 100644 index 00000000..4bb39b0e --- /dev/null +++ b/spn/ships/ship.go @@ -0,0 +1,220 @@ +package ships + +import ( + "errors" + "fmt" + "net" + + "github.com/tevino/abool" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/hub" +) + +const ( + defaultLoadSize = 4096 +) + +// ErrSunk is returned when a ship sunk, ie. the connection was lost. +var ErrSunk = errors.New("ship sunk") + +// Ship represents a network layer connection. +type Ship interface { + // String returns a human readable informational summary about the ship. + String() string + + // Transport returns the transport used for this ship. + Transport() *hub.Transport + + // IsMine returns whether the ship was launched from here. + IsMine() bool + + // IsSecure returns whether the ship provides transport security. + IsSecure() bool + + // Public returns whether the ship is marked as public. + Public() bool + + // MarkPublic marks the ship as public. + MarkPublic() + + // LoadSize returns the recommended data size that should be handed to Load(). + // This value will be most likely somehow related to the connection's MTU. + // Alternatively, using a multiple of LoadSize is also recommended. + LoadSize() int + + // Load loads data into the ship - ie. sends the data via the connection. + // Returns ErrSunk if the ship has already sunk earlier. + Load(data []byte) error + + // UnloadTo unloads data from the ship - ie. receives data from the + // connection - puts it into the buf. It returns the amount of data + // written and an optional error. + // Returns ErrSunk if the ship has already sunk earlier. + UnloadTo(buf []byte) (n int, err error) + + // LocalAddr returns the underlying local net.Addr of the connection. + LocalAddr() net.Addr + + // RemoteAddr returns the underlying remote net.Addr of the connection. + RemoteAddr() net.Addr + + // Sink closes the underlying connection and cleans up any related resources. + Sink() + + // MaskAddress masks the address, if enabled. + MaskAddress(addr net.Addr) string + // MaskIP masks an IP, if enabled. + MaskIP(ip net.IP) string + // Mask masks a value. + Mask(value []byte) string +} + +// ShipBase implements common functions to comply with the Ship interface. +type ShipBase struct { + // conn is the actual underlying connection. + conn net.Conn + // transport holds the transport definition of the ship. + transport *hub.Transport + + // mine specifies whether the ship was launched from here. + mine bool + // secure specifies whether the ship provides transport security. + secure bool + // public specifies whether the ship is public. + public *abool.AtomicBool + // bufSize specifies the size of the receive buffer. + bufSize int + // loadSize specifies the recommended data size that should be handed to Load(). + loadSize int + + // initial holds initial data from setting up the ship. + initial []byte + // sinking specifies if the connection is being closed. + sinking *abool.AtomicBool +} + +func (ship *ShipBase) initBase() { + // init + ship.sinking = abool.New() + ship.public = abool.New() + + // set default + if ship.loadSize == 0 { + ship.loadSize = defaultLoadSize + } + if ship.bufSize == 0 { + ship.bufSize = ship.loadSize + } +} + +// String returns a human readable informational summary about the ship. +func (ship *ShipBase) String() string { + if ship.mine { + return fmt.Sprintf("", ship.MaskAddress(ship.RemoteAddr()), ship.transport) + } + return fmt.Sprintf("", ship.MaskAddress(ship.RemoteAddr()), ship.transport) +} + +// Transport returns the transport used for this ship. +func (ship *ShipBase) Transport() *hub.Transport { + return ship.transport +} + +// IsMine returns whether the ship was launched from here. +func (ship *ShipBase) IsMine() bool { + return ship.mine +} + +// IsSecure returns whether the ship provides transport security. +func (ship *ShipBase) IsSecure() bool { + return ship.secure +} + +// Public returns whether the ship is marked as public. +func (ship *ShipBase) Public() bool { + return ship.public.IsSet() +} + +// MarkPublic marks the ship as public. +func (ship *ShipBase) MarkPublic() { + ship.public.Set() +} + +// LoadSize returns the recommended data size that should be handed to Load(). +// This value will be most likely somehow related to the connection's MTU. +// Alternatively, using a multiple of LoadSize is also recommended. +func (ship *ShipBase) LoadSize() int { + return ship.loadSize +} + +// Load loads data into the ship - ie. sends the data via the connection. +// Returns ErrSunk if the ship has already sunk earlier. +func (ship *ShipBase) Load(data []byte) error { + // Empty load is used as a signal to cease operaetion. + if len(data) == 0 { + if ship.sinking.SetToIf(false, true) { + _ = ship.conn.Close() + } + return nil + } + + // Send all given data. + n, err := ship.conn.Write(data) + switch { + case err != nil: + return err + case n == 0: + return errors.New("loaded 0 bytes") + case n < len(data): + // If not all data was sent, try again. + log.Debugf("spn/ships: %s only loaded %d/%d bytes", ship, n, len(data)) + data = data[n:] + return ship.Load(data) + } + + return nil +} + +// UnloadTo unloads data from the ship - ie. receives data from the +// connection - puts it into the buf. It returns the amount of data +// written and an optional error. +// Returns ErrSunk if the ship has already sunk earlier. +func (ship *ShipBase) UnloadTo(buf []byte) (n int, err error) { + // Process initial data, if there is any. + if ship.initial != nil { + // Copy as much data as possible. + copy(buf, ship.initial) + + // If buf was too small, skip the copied section. + if len(buf) < len(ship.initial) { + ship.initial = ship.initial[len(buf):] + return len(buf), nil + } + + // If everything was copied, unset the initial data. + n := len(ship.initial) + ship.initial = nil + return n, nil + } + + // Receive data. + return ship.conn.Read(buf) +} + +// LocalAddr returns the underlying local net.Addr of the connection. +func (ship *ShipBase) LocalAddr() net.Addr { + return ship.conn.LocalAddr() +} + +// RemoteAddr returns the underlying remote net.Addr of the connection. +func (ship *ShipBase) RemoteAddr() net.Addr { + return ship.conn.RemoteAddr() +} + +// Sink closes the underlying connection and cleans up any related resources. +func (ship *ShipBase) Sink() { + if ship.sinking.SetToIf(false, true) { + _ = ship.conn.Close() + } +} diff --git a/spn/ships/tcp.go b/spn/ships/tcp.go new file mode 100644 index 00000000..5ffd5b90 --- /dev/null +++ b/spn/ships/tcp.go @@ -0,0 +1,145 @@ +package ships + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/hub" +) + +// TCPShip is a ship that uses TCP. +type TCPShip struct { + ShipBase +} + +// TCPPier is a pier that uses TCP. +type TCPPier struct { + PierBase + + ctx context.Context + cancelCtx context.CancelFunc +} + +func init() { + Register("tcp", &Builder{ + LaunchShip: launchTCPShip, + EstablishPier: establishTCPPier, + }) +} + +func launchTCPShip(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error) { + var dialNet string + if ip4 := ip.To4(); ip4 != nil { + dialNet = "tcp4" + } else { + dialNet = "tcp6" + } + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + LocalAddr: conf.GetBindAddr(dialNet), + FallbackDelay: -1, // Disables Fast Fallback from IPv6 to IPv4. + KeepAlive: -1, // Disable keep-alive. + } + conn, err := dialer.DialContext(ctx, dialNet, net.JoinHostPort(ip.String(), portToA(transport.Port))) + if err != nil { + return nil, fmt.Errorf("failed to connect: %w", err) + } + + ship := &TCPShip{ + ShipBase: ShipBase{ + conn: conn, + transport: transport, + mine: true, + secure: false, + }, + } + + ship.calculateLoadSize(ip, nil, TCPHeaderMTUSize) + ship.initBase() + return ship, nil +} + +func establishTCPPier(transport *hub.Transport, dockingRequests chan Ship) (Pier, error) { + // Start listeners. + bindIPs := conf.GetBindIPs() + listeners := make([]net.Listener, 0, len(bindIPs)) + for _, bindIP := range bindIPs { + listener, err := net.ListenTCP("tcp", &net.TCPAddr{ + IP: bindIP, + Port: int(transport.Port), + }) + if err != nil { + return nil, fmt.Errorf("failed to listen: %w", err) + } + + listeners = append(listeners, listener) + log.Infof("spn/ships: tcp transport pier established on %s", listener.Addr()) + } + + // Create new pier. + pierCtx, cancelCtx := context.WithCancel(module.Ctx) + pier := &TCPPier{ + PierBase: PierBase{ + transport: transport, + listeners: listeners, + dockingRequests: dockingRequests, + }, + ctx: pierCtx, + cancelCtx: cancelCtx, + } + pier.initBase() + + // Start workers. + for _, listener := range pier.listeners { + serviceListener := listener + module.StartServiceWorker("accept TCP docking requests", 0, func(ctx context.Context) error { + return pier.dockingWorker(ctx, serviceListener) + }) + } + + return pier, nil +} + +func (pier *TCPPier) dockingWorker(_ context.Context, listener net.Listener) error { + for { + // Block until something happens. + conn, err := listener.Accept() + + // Check for errors. + switch { + case pier.ctx.Err() != nil: + return pier.ctx.Err() + case err != nil: + return err + } + + // Create new ship. + ship := &TCPShip{ + ShipBase: ShipBase{ + transport: pier.transport, + conn: conn, + mine: false, + secure: false, + }, + } + ship.calculateLoadSize(nil, conn.RemoteAddr(), TCPHeaderMTUSize) + ship.initBase() + + // Submit new docking request. + select { + case pier.dockingRequests <- ship: + case <-pier.ctx.Done(): + return pier.ctx.Err() + } + } +} + +// Abolish closes the underlying listener and cleans up any related resources. +func (pier *TCPPier) Abolish() { + pier.cancelCtx() + pier.PierBase.Abolish() +} diff --git a/spn/ships/testship.go b/spn/ships/testship.go new file mode 100644 index 00000000..6ec74b6e --- /dev/null +++ b/spn/ships/testship.go @@ -0,0 +1,154 @@ +package ships + +import ( + "net" + + "github.com/mr-tron/base58" + "github.com/tevino/abool" + + "github.com/safing/portmaster/spn/hub" +) + +// TestShip is a simulated ship that is used for testing higher level components. +type TestShip struct { + mine bool + secure bool + loadSize int + forward chan []byte + backward chan []byte + unloadTmp []byte + sinking *abool.AtomicBool +} + +// NewTestShip returns a new TestShip for simulation. +func NewTestShip(secure bool, loadSize int) *TestShip { + return &TestShip{ + mine: true, + secure: secure, + loadSize: loadSize, + forward: make(chan []byte, 100), + backward: make(chan []byte, 100), + sinking: abool.NewBool(false), + } +} + +// String returns a human readable informational summary about the ship. +func (ship *TestShip) String() string { + if ship.mine { + return "" + } + return "" +} + +// Transport returns the transport used for this ship. +func (ship *TestShip) Transport() *hub.Transport { + return &hub.Transport{ + Protocol: "dummy", + } +} + +// IsMine returns whether the ship was launched from here. +func (ship *TestShip) IsMine() bool { + return ship.mine +} + +// IsSecure returns whether the ship provides transport security. +func (ship *TestShip) IsSecure() bool { + return ship.secure +} + +// LoadSize returns the recommended data size that should be handed to Load(). +// This value will be most likely somehow related to the connection's MTU. +// Alternatively, using a multiple of LoadSize is also recommended. +func (ship *TestShip) LoadSize() int { + return ship.loadSize +} + +// Reverse creates a connected TestShip. This is used to simulate a connection instead of using a Pier. +func (ship *TestShip) Reverse() *TestShip { + return &TestShip{ + mine: !ship.mine, + secure: ship.secure, + loadSize: ship.loadSize, + forward: ship.backward, + backward: ship.forward, + sinking: abool.NewBool(false), + } +} + +// Load loads data into the ship - ie. sends the data via the connection. +// Returns ErrSunk if the ship has already sunk earlier. +func (ship *TestShip) Load(data []byte) error { + // Debugging: + // log.Debugf("spn/ship: loading %s", spew.Sdump(data)) + + // Check if ship is alive. + if ship.sinking.IsSet() { + return ErrSunk + } + + // Empty load is used as a signal to cease operaetion. + if len(data) == 0 { + ship.Sink() + return nil + } + + // Send all given data. + ship.forward <- data + + return nil +} + +// UnloadTo unloads data from the ship - ie. receives data from the +// connection - puts it into the buf. It returns the amount of data +// written and an optional error. +// Returns ErrSunk if the ship has already sunk earlier. +func (ship *TestShip) UnloadTo(buf []byte) (n int, err error) { + // Process unload tmp data, if there is any. + if ship.unloadTmp != nil { + // Copy as much data as possible. + copy(buf, ship.unloadTmp) + + // If buf was too small, skip the copied section. + if len(buf) < len(ship.unloadTmp) { + ship.unloadTmp = ship.unloadTmp[len(buf):] + return len(buf), nil + } + + // If everything was copied, unset the unloadTmp data. + n := len(ship.unloadTmp) + ship.unloadTmp = nil + return n, nil + } + + // Receive data. + data := <-ship.backward + if len(data) == 0 { + return 0, ErrSunk + } + + // Copy data, possibly save remainder for later. + copy(buf, data) + if len(buf) < len(data) { + ship.unloadTmp = data[len(buf):] + return len(buf), nil + } + return len(data), nil +} + +// Sink closes the underlying connection and cleans up any related resources. +func (ship *TestShip) Sink() { + if ship.sinking.SetToIf(false, true) { + close(ship.forward) + } +} + +// Dummy methods to conform to interface for testing. + +func (ship *TestShip) LocalAddr() net.Addr { return nil } //nolint:golint +func (ship *TestShip) RemoteAddr() net.Addr { return nil } //nolint:golint +func (ship *TestShip) Public() bool { return true } //nolint:golint +func (ship *TestShip) MarkPublic() {} //nolint:golint +func (ship *TestShip) MaskAddress(addr net.Addr) string { return addr.String() } //nolint:golint +func (ship *TestShip) MaskIP(ip net.IP) string { return ip.String() } //nolint:golint +func (ship *TestShip) Mask(value []byte) string { return base58.Encode(value) } //nolint:golint diff --git a/spn/ships/testship_test.go b/spn/ships/testship_test.go new file mode 100644 index 00000000..7e026b92 --- /dev/null +++ b/spn/ships/testship_test.go @@ -0,0 +1,58 @@ +package ships + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTestShip(t *testing.T) { + t.Parallel() + + tShip := NewTestShip(true, 100) + + // interface conformance test + var ship Ship = tShip + + srvShip := tShip.Reverse() + + for i := 0; i < 100; i++ { + // client send + err := ship.Load(testData) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // server recv + buf := getTestBuf() + _, err = srvShip.UnloadTo(buf) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // check data + assert.Equal(t, testData, buf, "should match") + fmt.Print(".") + + // server send + err = srvShip.Load(testData) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // client recv + buf = getTestBuf() + _, err = ship.UnloadTo(buf) + if err != nil { + t.Fatalf("%s failed: %s", ship, err) + } + + // check data + assert.Equal(t, testData, buf, "should match") + fmt.Print(".") + } + + ship.Sink() + srvShip.Sink() +} diff --git a/spn/ships/virtual_network.go b/spn/ships/virtual_network.go new file mode 100644 index 00000000..314112ef --- /dev/null +++ b/spn/ships/virtual_network.go @@ -0,0 +1,43 @@ +package ships + +import ( + "net" + "sync" + + "github.com/safing/portmaster/spn/hub" +) + +var ( + virtNetLock sync.Mutex + virtNetConfig *hub.VirtualNetworkConfig +) + +// SetVirtualNetworkConfig sets the virtual networking config. +func SetVirtualNetworkConfig(config *hub.VirtualNetworkConfig) { + virtNetLock.Lock() + defer virtNetLock.Unlock() + + virtNetConfig = config +} + +// GetVirtualNetworkConfig returns the virtual networking config. +func GetVirtualNetworkConfig() *hub.VirtualNetworkConfig { + virtNetLock.Lock() + defer virtNetLock.Unlock() + + return virtNetConfig +} + +// GetVirtualNetworkAddress returns the virtual network IP for the given Hub. +func GetVirtualNetworkAddress(dstHubID string) net.IP { + virtNetLock.Lock() + defer virtNetLock.Unlock() + + // Check if we have a virtual network config. + if virtNetConfig == nil { + return nil + } + + // Return mapping for given Hub ID. + return virtNetConfig.Mapping[dstHubID] +} diff --git a/spn/sluice/module.go b/spn/sluice/module.go new file mode 100644 index 00000000..63f1d2e0 --- /dev/null +++ b/spn/sluice/module.go @@ -0,0 +1,46 @@ +package sluice + +import ( + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portmaster/service/netenv" + "github.com/safing/portmaster/spn/conf" +) + +var ( + module *modules.Module + + entrypointInfoMsg = []byte("You have reached the local SPN entry port, but your connection could not be matched to an SPN tunnel.\n") + + // EnableListener indicates if it should start the sluice listeners. Must be set at startup. + EnableListener bool = true +) + +func init() { + module = modules.Register("sluice", nil, start, stop, "terminal") +} + +func start() error { + // TODO: + // Listening on all interfaces for now, as we need this for Windows. + // Handle similarly to the nameserver listener. + + if conf.Client() && EnableListener { + StartSluice("tcp4", "0.0.0.0:717") + StartSluice("udp4", "0.0.0.0:717") + + if netenv.IPv6Enabled() { + StartSluice("tcp6", "[::]:717") + StartSluice("udp6", "[::]:717") + } else { + log.Warningf("spn/sluice: no IPv6 stack detected, disabling IPv6 SPN entry endpoints") + } + } + + return nil +} + +func stop() error { + stopAllSluices() + return nil +} diff --git a/spn/sluice/packet_listener.go b/spn/sluice/packet_listener.go new file mode 100644 index 00000000..3eb64cbb --- /dev/null +++ b/spn/sluice/packet_listener.go @@ -0,0 +1,277 @@ +package sluice + +import ( + "context" + "io" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/tevino/abool" +) + +// PacketListener is a listener for packet based protocols. +type PacketListener struct { + sock net.PacketConn + closed *abool.AtomicBool + newConns chan *PacketConn + + lock sync.Mutex + conns map[string]*PacketConn + err error +} + +// ListenPacket creates a packet listener. +func ListenPacket(network, address string) (net.Listener, error) { + // Create a new listening packet socket. + sock, err := net.ListenPacket(network, address) + if err != nil { + return nil, err + } + + // Create listener and start workers. + ln := &PacketListener{ + sock: sock, + closed: abool.New(), + newConns: make(chan *PacketConn), + conns: make(map[string]*PacketConn), + } + module.StartServiceWorker("packet listener reader", 0, ln.reader) + module.StartServiceWorker("packet listener cleaner", time.Minute, ln.cleaner) + + return ln, nil +} + +// Accept waits for and returns the next connection to the listener. +func (ln *PacketListener) Accept() (net.Conn, error) { + conn := <-ln.newConns + if conn != nil { + return conn, nil + } + + // Check if there is a socket error. + ln.lock.Lock() + defer ln.lock.Unlock() + if ln.err != nil { + return nil, ln.err + } + + return nil, io.EOF +} + +// Close closes the listener. +// Any blocked Accept operations will be unblocked and return errors. +func (ln *PacketListener) Close() error { + if !ln.closed.SetToIf(false, true) { + return nil + } + + // Close all channels. + close(ln.newConns) + ln.lock.Lock() + defer ln.lock.Unlock() + for _, conn := range ln.conns { + close(conn.in) + } + + // Close socket. + return ln.sock.Close() +} + +// Addr returns the listener's network address. +func (ln *PacketListener) Addr() net.Addr { + return ln.sock.LocalAddr() +} + +func (ln *PacketListener) getConn(remoteAddr string) (conn *PacketConn, ok bool) { + ln.lock.Lock() + defer ln.lock.Unlock() + + conn, ok = ln.conns[remoteAddr] + return +} + +func (ln *PacketListener) setConn(conn *PacketConn) { + ln.lock.Lock() + defer ln.lock.Unlock() + + ln.conns[conn.addr.String()] = conn +} + +func (ln *PacketListener) reader(_ context.Context) error { + for { + // Read data from connection. + buf := make([]byte, 512) + n, addr, err := ln.sock.ReadFrom(buf) + if err != nil { + // Set socket error. + ln.lock.Lock() + ln.err = err + ln.lock.Unlock() + // Close and return + _ = ln.Close() + return nil //nolint:nilerr + } + buf = buf[:n] + + // Get connection and supply data. + conn, ok := ln.getConn(addr.String()) + if ok { + // Ignore if conn is closed. + if conn.closed.IsSet() { + continue + } + + select { + case conn.in <- buf: + default: + } + continue + } + + // Or create a new connection. + conn = &PacketConn{ + ln: ln, + addr: addr, + closed: abool.New(), + closing: make(chan struct{}), + buf: buf, + in: make(chan []byte, 1), + inactivityCnt: new(uint32), + } + ln.setConn(conn) + ln.newConns <- conn + } +} + +func (ln *PacketListener) cleaner(ctx context.Context) error { + for { + select { + case <-time.After(1 * time.Minute): + // Check if listener has died. + if ln.closed.IsSet() { + return nil + } + // Clean connections. + ln.cleanInactiveConns(10) + + case <-ctx.Done(): + // Exit with module stop. + return nil + } + } +} + +func (ln *PacketListener) cleanInactiveConns(overInactivityCnt uint32) { + ln.lock.Lock() + defer ln.lock.Unlock() + + for k, conn := range ln.conns { + cnt := atomic.AddUint32(conn.inactivityCnt, 1) + switch { + case cnt > overInactivityCnt*2: + delete(ln.conns, k) + case cnt > overInactivityCnt: + _ = conn.Close() + } + } +} + +// PacketConn simulates a connection for a stateless protocol. +type PacketConn struct { + ln *PacketListener + addr net.Addr + closed *abool.AtomicBool + closing chan struct{} + + buf []byte + in chan []byte + + inactivityCnt *uint32 +} + +// Read reads data from the connection. +// Read can be made to time out and return an error after a fixed +// time limit; see SetDeadline and SetReadDeadline. +func (conn *PacketConn) Read(b []byte) (n int, err error) { + // Check if connection is closed. + if conn.closed.IsSet() { + return 0, io.EOF + } + + // Mark as active. + atomic.StoreUint32(conn.inactivityCnt, 0) + + // Get new buffer. + if conn.buf == nil { + select { + case conn.buf = <-conn.in: + if conn.buf == nil { + return 0, io.EOF + } + case <-conn.closing: + return 0, io.EOF + } + } + + // Serve from buffer. + copy(b, conn.buf) + if len(b) >= len(conn.buf) { + copied := len(conn.buf) + conn.buf = nil + return copied, nil + } + copied := len(b) + conn.buf = conn.buf[copied:] + return copied, nil +} + +// Write writes data to the connection. +// Write can be made to time out and return an error after a fixed +// time limit; see SetDeadline and SetWriteDeadline. +func (conn *PacketConn) Write(b []byte) (n int, err error) { + // Check if connection is closed. + if conn.closed.IsSet() { + return 0, io.EOF + } + + // Mark as active. + atomic.StoreUint32(conn.inactivityCnt, 0) + + return conn.ln.sock.WriteTo(b, conn.addr) +} + +// Close is a no-op as UDP connections share a single socket. Just stop sending +// packets without closing. +func (conn *PacketConn) Close() error { + if conn.closed.SetToIf(false, true) { + close(conn.closing) + } + return nil +} + +// LocalAddr returns the local network address. +func (conn *PacketConn) LocalAddr() net.Addr { + return conn.ln.sock.LocalAddr() +} + +// RemoteAddr returns the remote network address. +func (conn *PacketConn) RemoteAddr() net.Addr { + return conn.addr +} + +// SetDeadline is a no-op as UDP connections share a single socket. +func (conn *PacketConn) SetDeadline(t time.Time) error { + return nil +} + +// SetReadDeadline is a no-op as UDP connections share a single socket. +func (conn *PacketConn) SetReadDeadline(t time.Time) error { + return nil +} + +// SetWriteDeadline is a no-op as UDP connections share a single socket. +func (conn *PacketConn) SetWriteDeadline(t time.Time) error { + return nil +} diff --git a/spn/sluice/request.go b/spn/sluice/request.go new file mode 100644 index 00000000..2347ed35 --- /dev/null +++ b/spn/sluice/request.go @@ -0,0 +1,78 @@ +package sluice + +import ( + "errors" + "fmt" + "net" + "time" + + "github.com/safing/portmaster/service/network" + "github.com/safing/portmaster/service/network/packet" +) + +const ( + defaultSluiceTTL = 30 * time.Second +) + +var ( + // ErrUnsupported is returned when a protocol is not supported. + ErrUnsupported = errors.New("unsupported protocol") + + // ErrSluiceOffline is returned when the sluice for a network is offline. + ErrSluiceOffline = errors.New("is offline") +) + +// Request holds request data for a sluice entry. +type Request struct { + ConnInfo *network.Connection + CallbackFn RequestCallbackFunc + Expires time.Time +} + +// RequestCallbackFunc is called for taking a over handling connection that arrived at the sluice. +type RequestCallbackFunc func(connInfo *network.Connection, conn net.Conn) + +// AwaitRequest pre-registers a connection at the sluice for initializing it when it arrives. +func AwaitRequest(connInfo *network.Connection, callbackFn RequestCallbackFunc) error { + network := getNetworkFromConnInfo(connInfo) + if network == "" { + return ErrUnsupported + } + + sluice, ok := getSluice(network) + if !ok { + return fmt.Errorf("sluice for network %s %w", network, ErrSluiceOffline) + } + + return sluice.AwaitRequest(&Request{ + ConnInfo: connInfo, + CallbackFn: callbackFn, + Expires: time.Now().Add(defaultSluiceTTL), + }) +} + +func getNetworkFromConnInfo(connInfo *network.Connection) string { + var network string + + // protocol + switch connInfo.IPProtocol { //nolint:exhaustive // Looking for specific values. + case packet.TCP: + network = "tcp" + case packet.UDP: + network = "udp" + default: + return "" + } + + // IP version + switch connInfo.IPVersion { + case packet.IPv4: + network += "4" + case packet.IPv6: + network += "6" + default: + return "" + } + + return network +} diff --git a/spn/sluice/sluice.go b/spn/sluice/sluice.go new file mode 100644 index 00000000..bc136b10 --- /dev/null +++ b/spn/sluice/sluice.go @@ -0,0 +1,229 @@ +package sluice + +import ( + "context" + "fmt" + "net" + "strconv" + "sync" + "time" + + "github.com/safing/portbase/log" + "github.com/safing/portmaster/service/netenv" +) + +// Sluice is a tunnel entry listener. +type Sluice struct { + network string + address string + createListener ListenerFactory + + lock sync.Mutex + listener net.Listener + pendingRequests map[string]*Request + abandoned bool +} + +// ListenerFactory defines a function to create a listener. +type ListenerFactory func(network, address string) (net.Listener, error) + +// StartSluice starts a sluice listener at the given address. +func StartSluice(network, address string) { + s := &Sluice{ + network: network, + address: address, + pendingRequests: make(map[string]*Request), + } + + switch s.network { + case "tcp4", "tcp6": + s.createListener = net.Listen + case "udp4", "udp6": + s.createListener = ListenUDP + default: + log.Errorf("spn/sluice: cannot start sluice for %s: unsupported network", network) + return + } + + // Start service worker. + module.StartServiceWorker( + s.network+" sluice listener", + 10*time.Second, + s.listenHandler, + ) +} + +// AwaitRequest pre-registers a connection. +func (s *Sluice) AwaitRequest(r *Request) error { + // Set default expiry. + if r.Expires.IsZero() { + r.Expires = time.Now().Add(defaultSluiceTTL) + } + + s.lock.Lock() + defer s.lock.Unlock() + + // Check if a pending request already exists for this local address. + key := net.JoinHostPort(r.ConnInfo.LocalIP.String(), strconv.Itoa(int(r.ConnInfo.LocalPort))) + _, exists := s.pendingRequests[key] + if exists { + return fmt.Errorf("a pending request for %s already exists", key) + } + + // Add to pending requests. + s.pendingRequests[key] = r + return nil +} + +func (s *Sluice) getRequest(address string) (r *Request, ok bool) { + s.lock.Lock() + defer s.lock.Unlock() + + r, ok = s.pendingRequests[address] + if ok { + delete(s.pendingRequests, address) + } + return +} + +func (s *Sluice) init() error { + s.lock.Lock() + defer s.lock.Unlock() + s.abandoned = false + + // start listening + s.listener = nil + ln, err := s.createListener(s.network, s.address) + if err != nil { + return fmt.Errorf("failed to listen: %w", err) + } + s.listener = ln + + // Add to registry. + addSluice(s) + + return nil +} + +func (s *Sluice) abandon() { + s.lock.Lock() + defer s.lock.Unlock() + if s.abandoned { + return + } + s.abandoned = true + + // Remove from registry. + removeSluice(s.network) + + // Close listener. + if s.listener != nil { + _ = s.listener.Close() + } + + // Notify pending requests. + for i, r := range s.pendingRequests { + r.CallbackFn(r.ConnInfo, nil) + delete(s.pendingRequests, i) + } +} + +func (s *Sluice) handleConnection(conn net.Conn) { + // Close the connection if handling is not successful. + success := false + defer func() { + if !success { + _ = conn.Close() + } + }() + + // Get IP address. + var remoteIP net.IP + switch typedAddr := conn.RemoteAddr().(type) { + case *net.TCPAddr: + remoteIP = typedAddr.IP + case *net.UDPAddr: + remoteIP = typedAddr.IP + default: + log.Warningf("spn/sluice: cannot handle connection for unsupported network %s", conn.RemoteAddr().Network()) + return + } + + // Check if the request is local. + local, err := netenv.IsMyIP(remoteIP) + if err != nil { + log.Warningf("spn/sluice: failed to check if request from %s is local: %s", remoteIP, err) + return + } + if !local { + log.Warningf("spn/sluice: received external request from %s, ignoring", remoteIP) + + // TODO: + // Do not allow this to be spammed. + // Only allow one trigger per second. + // Do not trigger by same "remote IP" in a row. + netenv.TriggerNetworkChangeCheck() + + return + } + + // Get waiting request. + r, ok := s.getRequest(conn.RemoteAddr().String()) + if !ok { + _, err := conn.Write(entrypointInfoMsg) + if err != nil { + log.Warningf("spn/sluice: new %s request from %s without pending request, but failed to reply with info msg: %s", s.network, conn.RemoteAddr(), err) + } else { + log.Debugf("spn/sluice: new %s request from %s without pending request, replied with info msg", s.network, conn.RemoteAddr()) + } + return + } + + // Hand over to callback. + log.Tracef( + "spn/sluice: new %s request from %s for %s (%s:%d)", + s.network, conn.RemoteAddr(), + r.ConnInfo.Entity.Domain, r.ConnInfo.Entity.IP, r.ConnInfo.Entity.Port, + ) + r.CallbackFn(r.ConnInfo, conn) + success = true +} + +func (s *Sluice) listenHandler(_ context.Context) error { + defer s.abandon() + err := s.init() + if err != nil { + return err + } + + // Handle new connections. + log.Infof("spn/sluice: started listening for %s requests on %s", s.network, s.listener.Addr()) + for { + conn, err := s.listener.Accept() + if err != nil { + if module.IsStopping() { + return nil + } + return fmt.Errorf("failed to accept connection: %w", err) + } + + // Handle accepted connection. + s.handleConnection(conn) + + // Clean up old leftovers. + s.cleanConnections() + } +} + +func (s *Sluice) cleanConnections() { + s.lock.Lock() + defer s.lock.Unlock() + + now := time.Now() + for address, request := range s.pendingRequests { + if now.After(request.Expires) { + delete(s.pendingRequests, address) + log.Debugf("spn/sluice: removed expired pending %s connection %s", s.network, request.ConnInfo) + } + } +} diff --git a/spn/sluice/sluices.go b/spn/sluice/sluices.go new file mode 100644 index 00000000..1ae58777 --- /dev/null +++ b/spn/sluice/sluices.go @@ -0,0 +1,47 @@ +package sluice + +import "sync" + +var ( + sluices = make(map[string]*Sluice) + sluicesLock sync.RWMutex +) + +func getSluice(network string) (s *Sluice, ok bool) { + sluicesLock.RLock() + defer sluicesLock.RUnlock() + + s, ok = sluices[network] + return +} + +func addSluice(s *Sluice) { + sluicesLock.Lock() + defer sluicesLock.Unlock() + + sluices[s.network] = s +} + +func removeSluice(network string) { + sluicesLock.Lock() + defer sluicesLock.Unlock() + + delete(sluices, network) +} + +func copySluices() map[string]*Sluice { + sluicesLock.Lock() + defer sluicesLock.Unlock() + + copied := make(map[string]*Sluice, len(sluices)) + for k, v := range sluices { + copied[k] = v + } + return copied +} + +func stopAllSluices() { + for _, sluice := range copySluices() { + sluice.abandon() + } +} diff --git a/spn/sluice/udp_listener.go b/spn/sluice/udp_listener.go new file mode 100644 index 00000000..4065d520 --- /dev/null +++ b/spn/sluice/udp_listener.go @@ -0,0 +1,334 @@ +package sluice + +import ( + "context" + "io" + "net" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/tevino/abool" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +const onWindows = runtime.GOOS == "windows" + +// UDPListener is a listener for UDP. +type UDPListener struct { + sock *net.UDPConn + closed *abool.AtomicBool + newConns chan *UDPConn + oobSize int + + lock sync.Mutex + conns map[string]*UDPConn + err error +} + +// ListenUDP creates a packet listener. +func ListenUDP(network, address string) (net.Listener, error) { + // Parse address. + udpAddr, err := net.ResolveUDPAddr(network, address) + if err != nil { + return nil, err + } + + // Determine oob data size. + oobSize := 40 // IPv6 (measured) + if udpAddr.IP.To4() != nil { + oobSize = 32 // IPv4 (measured) + } + + // Create a new listening UDP socket. + sock, err := net.ListenUDP(network, udpAddr) + if err != nil { + return nil, err + } + + // Create listener. + ln := &UDPListener{ + sock: sock, + closed: abool.New(), + newConns: make(chan *UDPConn), + oobSize: oobSize, + conns: make(map[string]*UDPConn), + } + + // Set socket options on listener. + err = ln.setSocketOptions() + if err != nil { + return nil, err + } + + // Start workers. + module.StartServiceWorker("udp listener reader", 0, ln.reader) + module.StartServiceWorker("udp listener cleaner", time.Minute, ln.cleaner) + + return ln, nil +} + +// Accept waits for and returns the next connection to the listener. +func (ln *UDPListener) Accept() (net.Conn, error) { + conn := <-ln.newConns + if conn != nil { + return conn, nil + } + + // Check if there is a socket error. + ln.lock.Lock() + defer ln.lock.Unlock() + if ln.err != nil { + return nil, ln.err + } + + return nil, io.EOF +} + +// Close closes the listener. +// Any blocked Accept operations will be unblocked and return errors. +func (ln *UDPListener) Close() error { + if !ln.closed.SetToIf(false, true) { + return nil + } + + // Close all channels. + close(ln.newConns) + ln.lock.Lock() + defer ln.lock.Unlock() + for _, conn := range ln.conns { + close(conn.in) + } + + // Close socket. + return ln.sock.Close() +} + +// Addr returns the listener's network address. +func (ln *UDPListener) Addr() net.Addr { + return ln.sock.LocalAddr() +} + +func (ln *UDPListener) getConn(remoteAddr string) (conn *UDPConn, ok bool) { + ln.lock.Lock() + defer ln.lock.Unlock() + + conn, ok = ln.conns[remoteAddr] + return +} + +func (ln *UDPListener) setConn(conn *UDPConn) { + ln.lock.Lock() + defer ln.lock.Unlock() + + ln.conns[conn.addr.String()] = conn +} + +func (ln *UDPListener) reader(_ context.Context) error { + for { + // TODO: Find good buf size. + // With a buf size of 512 we have seen this error on Windows: + // wsarecvmsg: A message sent on a datagram socket was larger than the internal message buffer or some other network limit, or the buffer used to receive a datagram into was smaller than the datagram itself. + // UDP is not (yet) heavily used, so we can go for the 1500 bytes size for now. + + // Read data from connection. + buf := make([]byte, 1500) // TODO: see comment above. + oob := make([]byte, ln.oobSize) + n, oobn, _, addr, err := ln.sock.ReadMsgUDP(buf, oob) + if err != nil { + // Set socket error. + ln.lock.Lock() + ln.err = err + ln.lock.Unlock() + // Close and return + _ = ln.Close() + return nil //nolint:nilerr + } + buf = buf[:n] + oob = oob[:oobn] + + // Get connection and supply data. + conn, ok := ln.getConn(addr.String()) + if ok { + // Ignore if conn is closed. + if conn.closed.IsSet() { + continue + } + + select { + case conn.in <- buf: + default: + } + continue + } + + // Or create a new connection. + conn = &UDPConn{ + ln: ln, + addr: addr, + oob: oob, + closed: abool.New(), + closing: make(chan struct{}), + buf: buf, + in: make(chan []byte, 1), + inactivityCnt: new(uint32), + } + ln.setConn(conn) + ln.newConns <- conn + } +} + +func (ln *UDPListener) cleaner(ctx context.Context) error { + for { + select { + case <-time.After(1 * time.Minute): + // Check if listener has died. + if ln.closed.IsSet() { + return nil + } + // Clean connections. + ln.cleanInactiveConns(10) + + case <-ctx.Done(): + // Exit with module stop. + return nil + } + } +} + +func (ln *UDPListener) cleanInactiveConns(overInactivityCnt uint32) { + ln.lock.Lock() + defer ln.lock.Unlock() + + for k, conn := range ln.conns { + cnt := atomic.AddUint32(conn.inactivityCnt, 1) + switch { + case cnt > overInactivityCnt*2: + delete(ln.conns, k) + case cnt > overInactivityCnt: + _ = conn.Close() + } + } +} + +// setUDPSocketOptions sets socket options so that the source address for +// replies is correct. +func (ln *UDPListener) setSocketOptions() error { + // Setting socket options is not supported on windows. + if onWindows { + return nil + } + + // As we might be listening on an interface that supports both IPv4 and IPv6, + // try to set the socket options on both. + // Only report an error if it fails on both. + err4 := ipv4.NewPacketConn(ln.sock).SetControlMessage(ipv4.FlagDst|ipv4.FlagInterface, true) + err6 := ipv6.NewPacketConn(ln.sock).SetControlMessage(ipv6.FlagDst|ipv6.FlagInterface, true) + if err4 != nil && err6 != nil { + return err4 + } + + return nil +} + +// UDPConn simulates a connection for a stateless protocol. +type UDPConn struct { + ln *UDPListener + addr *net.UDPAddr + oob []byte + closed *abool.AtomicBool + closing chan struct{} + + buf []byte + in chan []byte + + inactivityCnt *uint32 +} + +// Read reads data from the connection. +// Read can be made to time out and return an error after a fixed +// time limit; see SetDeadline and SetReadDeadline. +func (conn *UDPConn) Read(b []byte) (n int, err error) { + // Check if connection is closed. + if conn.closed.IsSet() { + return 0, io.EOF + } + + // Mark as active. + atomic.StoreUint32(conn.inactivityCnt, 0) + + // Get new buffer. + if conn.buf == nil { + select { + case conn.buf = <-conn.in: + if conn.buf == nil { + return 0, io.EOF + } + case <-conn.closing: + return 0, io.EOF + } + } + + // Serve from buffer. + copy(b, conn.buf) + if len(b) >= len(conn.buf) { + copied := len(conn.buf) + conn.buf = nil + return copied, nil + } + copied := len(b) + conn.buf = conn.buf[copied:] + return copied, nil +} + +// Write writes data to the connection. +// Write can be made to time out and return an error after a fixed +// time limit; see SetDeadline and SetWriteDeadline. +func (conn *UDPConn) Write(b []byte) (n int, err error) { + // Check if connection is closed. + if conn.closed.IsSet() { + return 0, io.EOF + } + + // Mark as active. + atomic.StoreUint32(conn.inactivityCnt, 0) + + n, _, err = conn.ln.sock.WriteMsgUDP(b, conn.oob, conn.addr) + return n, err +} + +// Close is a no-op as UDP connections share a single socket. Just stop sending +// packets without closing. +func (conn *UDPConn) Close() error { + if conn.closed.SetToIf(false, true) { + close(conn.closing) + } + return nil +} + +// LocalAddr returns the local network address. +func (conn *UDPConn) LocalAddr() net.Addr { + return conn.ln.sock.LocalAddr() +} + +// RemoteAddr returns the remote network address. +func (conn *UDPConn) RemoteAddr() net.Addr { + return conn.addr +} + +// SetDeadline is a no-op as UDP connections share a single socket. +func (conn *UDPConn) SetDeadline(t time.Time) error { + return nil +} + +// SetReadDeadline is a no-op as UDP connections share a single socket. +func (conn *UDPConn) SetReadDeadline(t time.Time) error { + return nil +} + +// SetWriteDeadline is a no-op as UDP connections share a single socket. +func (conn *UDPConn) SetWriteDeadline(t time.Time) error { + return nil +} diff --git a/spn/spn.go b/spn/spn.go new file mode 100644 index 00000000..569d85de --- /dev/null +++ b/spn/spn.go @@ -0,0 +1 @@ +package spn diff --git a/spn/terminal/control_flow.go b/spn/terminal/control_flow.go new file mode 100644 index 00000000..e4d15ccf --- /dev/null +++ b/spn/terminal/control_flow.go @@ -0,0 +1,454 @@ +package terminal + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/modules" +) + +// FlowControl defines the flow control interface. +type FlowControl interface { + Deliver(msg *Msg) *Error + Receive() <-chan *Msg + Send(msg *Msg, timeout time.Duration) *Error + ReadyToSend() <-chan struct{} + Flush(timeout time.Duration) + StartWorkers(m *modules.Module, terminalName string) + RecvQueueLen() int + SendQueueLen() int +} + +// FlowControlType represents a flow control type. +type FlowControlType uint8 + +// Flow Control Types. +const ( + FlowControlDefault FlowControlType = 0 + FlowControlDFQ FlowControlType = 1 + FlowControlNone FlowControlType = 2 + + defaultFlowControl = FlowControlDFQ +) + +// DefaultSize returns the default flow control size. +func (fct FlowControlType) DefaultSize() uint32 { + if fct == FlowControlDefault { + fct = defaultFlowControl + } + + switch fct { + case FlowControlDFQ: + return 50000 + case FlowControlNone: + return 10000 + case FlowControlDefault: + fallthrough + default: + return 0 + } +} + +// Flow Queue Configuration. +const ( + DefaultQueueSize = 50000 + MaxQueueSize = 1000000 + forceReportBelowPercent = 0.75 +) + +// DuplexFlowQueue is a duplex flow control mechanism using queues. +type DuplexFlowQueue struct { + // ti is the Terminal that is using the DFQ. + ctx context.Context + + // submitUpstream is used to submit messages to the upstream channel. + submitUpstream func(msg *Msg, timeout time.Duration) + + // sendQueue holds the messages that are waiting to be sent. + sendQueue chan *Msg + // prioMsgs holds the number of messages to send with high priority. + prioMsgs *int32 + // sendSpace indicates the amount free slots in the recvQueue on the other end. + sendSpace *int32 + // readyToSend is used to notify sending components that there is free space. + readyToSend chan struct{} + // wakeSender is used to wake a sender in case the sendSpace was zero and the + // sender is waiting for available space. + wakeSender chan struct{} + + // recvQueue holds the messages that are waiting to be processed. + recvQueue chan *Msg + // reportedSpace indicates the amount of free slots that the other end knows + // about. + reportedSpace *int32 + // spaceReportLock locks the calculation of space to report. + spaceReportLock sync.Mutex + // forceSpaceReport forces the sender to send a space report. + forceSpaceReport chan struct{} + + // flush is used to send a finish function to the handler, which will write + // all pending messages and then call the received function. + flush chan func() +} + +// NewDuplexFlowQueue returns a new duplex flow queue. +func NewDuplexFlowQueue( + ctx context.Context, + queueSize uint32, + submitUpstream func(msg *Msg, timeout time.Duration), +) *DuplexFlowQueue { + dfq := &DuplexFlowQueue{ + ctx: ctx, + submitUpstream: submitUpstream, + sendQueue: make(chan *Msg, queueSize), + prioMsgs: new(int32), + sendSpace: new(int32), + readyToSend: make(chan struct{}), + wakeSender: make(chan struct{}, 1), + recvQueue: make(chan *Msg, queueSize), + reportedSpace: new(int32), + forceSpaceReport: make(chan struct{}, 1), + flush: make(chan func()), + } + atomic.StoreInt32(dfq.sendSpace, int32(queueSize)) + atomic.StoreInt32(dfq.reportedSpace, int32(queueSize)) + + return dfq +} + +// StartWorkers starts the necessary workers to operate the flow queue. +func (dfq *DuplexFlowQueue) StartWorkers(m *modules.Module, terminalName string) { + m.StartWorker(terminalName+" flow queue", dfq.FlowHandler) +} + +// shouldReportRecvSpace returns whether the receive space should be reported. +func (dfq *DuplexFlowQueue) shouldReportRecvSpace() bool { + return atomic.LoadInt32(dfq.reportedSpace) < int32(float32(cap(dfq.recvQueue))*forceReportBelowPercent) +} + +// decrementReportedRecvSpace decreases the reported recv space by 1 and +// returns if the receive space should be reported. +func (dfq *DuplexFlowQueue) decrementReportedRecvSpace() (shouldReportRecvSpace bool) { + return atomic.AddInt32(dfq.reportedSpace, -1) < int32(float32(cap(dfq.recvQueue))*forceReportBelowPercent) +} + +// getSendSpace returns the current send space. +func (dfq *DuplexFlowQueue) getSendSpace() int32 { + return atomic.LoadInt32(dfq.sendSpace) +} + +// decrementSendSpace decreases the send space by 1 and returns it. +func (dfq *DuplexFlowQueue) decrementSendSpace() int32 { + return atomic.AddInt32(dfq.sendSpace, -1) +} + +func (dfq *DuplexFlowQueue) addToSendSpace(n int32) { + // Add new space to send space and check if it was zero. + atomic.AddInt32(dfq.sendSpace, n) + // Wake the sender in case it is waiting. + select { + case dfq.wakeSender <- struct{}{}: + default: + } +} + +// reportableRecvSpace returns how much free space can be reported to the other +// end. The returned number must be communicated to the other end and must not +// be ignored. +func (dfq *DuplexFlowQueue) reportableRecvSpace() int32 { + // Changes to the recvQueue during calculation are no problem. + // We don't want to report space twice though! + dfq.spaceReportLock.Lock() + defer dfq.spaceReportLock.Unlock() + + // Calculate reportable receive space and add it to the reported space. + reportedSpace := atomic.LoadInt32(dfq.reportedSpace) + toReport := int32(cap(dfq.recvQueue)-len(dfq.recvQueue)) - reportedSpace + + // Never report values below zero. + // This can happen, as dfq.reportedSpace is decreased after a container is + // submitted to dfq.recvQueue by dfq.Deliver(). This race condition can only + // lower the space to report, not increase it. A simple check here solved + // this problem and keeps performance high. + // Also, don't report values of 1, as the benefit is minimal and this might + // be commonly triggered due to the buffer of the force report channel. + if toReport <= 1 { + return 0 + } + + // Add space to report to dfq.reportedSpace and return it. + atomic.AddInt32(dfq.reportedSpace, toReport) + return toReport +} + +// FlowHandler handles all flow queue internals and must be started as a worker +// in the module where it is used. +func (dfq *DuplexFlowQueue) FlowHandler(_ context.Context) error { + // The upstreamSender is started by the terminal module, but is tied to the + // flow owner instead. Make sure that the flow owner's module depends on the + // terminal module so that it is shut down earlier. + + var sendSpaceDepleted bool + var flushFinished func() + + // Drain all queues when shutting down. + defer func() { + for { + select { + case msg := <-dfq.sendQueue: + msg.Finish() + case msg := <-dfq.recvQueue: + msg.Finish() + default: + return + } + } + }() + +sending: + for { + // If the send queue is depleted, wait to be woken. + if sendSpaceDepleted { + select { + case <-dfq.wakeSender: + if dfq.getSendSpace() > 0 { + sendSpaceDepleted = false + } else { + continue sending + } + + case <-dfq.forceSpaceReport: + // Forced reporting of space. + // We do not need to check if there is enough sending space, as there is + // no data included. + spaceToReport := dfq.reportableRecvSpace() + if spaceToReport > 0 { + msg := NewMsg(varint.Pack64(uint64(spaceToReport))) + dfq.submitUpstream(msg, 0) + } + continue sending + + case <-dfq.ctx.Done(): + return nil + } + } + + // Get message from send queue. + + select { + case dfq.readyToSend <- struct{}{}: + // Notify that we are ready to send. + + case msg := <-dfq.sendQueue: + // Send message from queue. + + // If nil, the queue is being shut down. + if msg == nil { + return nil + } + + // Check if we are handling a high priority message or waiting for one. + // Mark any msgs as high priority, when there is one in the pipeline. + remainingPrioMsgs := atomic.AddInt32(dfq.prioMsgs, -1) + switch { + case remainingPrioMsgs >= 0: + msg.Unit.MakeHighPriority() + case remainingPrioMsgs < -30_000: + // Prevent wrap to positive. + // Compatible with int16 or bigger. + atomic.StoreInt32(dfq.prioMsgs, 0) + } + + // Wait for processing slot. + msg.Unit.WaitForSlot() + + // Prepend available receiving space. + msg.Data.Prepend(varint.Pack64(uint64(dfq.reportableRecvSpace()))) + + // Submit for sending upstream. + dfq.submitUpstream(msg, 0) + // Decrease the send space and set flag if depleted. + if dfq.decrementSendSpace() <= 0 { + sendSpaceDepleted = true + } + + // Check if the send queue is empty now and signal flushers. + if flushFinished != nil && len(dfq.sendQueue) == 0 { + flushFinished() + flushFinished = nil + } + + case <-dfq.forceSpaceReport: + // Forced reporting of space. + // We do not need to check if there is enough sending space, as there is + // no data included. + spaceToReport := dfq.reportableRecvSpace() + if spaceToReport > 0 { + msg := NewMsg(varint.Pack64(uint64(spaceToReport))) + dfq.submitUpstream(msg, 0) + } + + case newFlushFinishedFn := <-dfq.flush: + // Signal immediately if send queue is empty. + if len(dfq.sendQueue) == 0 { + newFlushFinishedFn() + } else { + // If there already is a flush finished function, stack them. + if flushFinished != nil { + stackedFlushFinishFn := flushFinished + flushFinished = func() { + stackedFlushFinishFn() + newFlushFinishedFn() + } + } else { + flushFinished = newFlushFinishedFn + } + } + + case <-dfq.ctx.Done(): + return nil + } + } +} + +// Flush waits for all waiting data to be sent. +func (dfq *DuplexFlowQueue) Flush(timeout time.Duration) { + // Create channel and function for notifying. + wait := make(chan struct{}) + finished := func() { + close(wait) + } + // Request flush and return when stopping. + select { + case dfq.flush <- finished: + case <-dfq.ctx.Done(): + return + case <-TimedOut(timeout): + return + } + // Wait for flush to finish and return when stopping. + select { + case <-wait: + case <-dfq.ctx.Done(): + case <-TimedOut(timeout): + } +} + +var ready = make(chan struct{}) + +func init() { + close(ready) +} + +// ReadyToSend returns a channel that can be read when data can be sent. +func (dfq *DuplexFlowQueue) ReadyToSend() <-chan struct{} { + if atomic.LoadInt32(dfq.sendSpace) > 0 { + return ready + } + return dfq.readyToSend +} + +// Send adds the given container to the send queue. +func (dfq *DuplexFlowQueue) Send(msg *Msg, timeout time.Duration) *Error { + select { + case dfq.sendQueue <- msg: + if msg.Unit.IsHighPriority() { + // Reset prioMsgs to the current queue size, so that all waiting and the + // message we just added are all handled as high priority. + atomic.StoreInt32(dfq.prioMsgs, int32(len(dfq.sendQueue))) + } + return nil + + case <-TimedOut(timeout): + msg.Finish() + return ErrTimeout + + case <-dfq.ctx.Done(): + msg.Finish() + return ErrStopping + } +} + +// Receive receives a container from the recv queue. +func (dfq *DuplexFlowQueue) Receive() <-chan *Msg { + // If the reported recv space is nearing its end, force a report. + if dfq.shouldReportRecvSpace() { + select { + case dfq.forceSpaceReport <- struct{}{}: + default: + } + } + + return dfq.recvQueue +} + +// Deliver submits a container for receiving from upstream. +func (dfq *DuplexFlowQueue) Deliver(msg *Msg) *Error { + // Ignore nil containers. + if msg == nil || msg.Data == nil { + msg.Finish() + return ErrMalformedData.With("no data") + } + + // Get and add new reported space. + addSpace, err := msg.Data.GetNextN16() + if err != nil { + msg.Finish() + return ErrMalformedData.With("failed to parse reported space: %w", err) + } + if addSpace > 0 { + dfq.addToSendSpace(int32(addSpace)) + } + // Abort processing if the container only contained a space update. + if !msg.Data.HoldsData() { + msg.Finish() + return nil + } + + select { + case dfq.recvQueue <- msg: + + // If the recv queue accepted the Container, decrement the recv space. + shouldReportRecvSpace := dfq.decrementReportedRecvSpace() + // If the reported recv space is nearing its end, force a report, if the + // sender worker is idle. + if shouldReportRecvSpace { + select { + case dfq.forceSpaceReport <- struct{}{}: + default: + } + } + + return nil + default: + // If the recv queue is full, return an error. + // The whole point of the flow queue is to guarantee that this never happens. + msg.Finish() + return ErrQueueOverflow + } +} + +// FlowStats returns a k=v formatted string of internal stats. +func (dfq *DuplexFlowQueue) FlowStats() string { + return fmt.Sprintf( + "sq=%d rq=%d sends=%d reps=%d", + len(dfq.sendQueue), + len(dfq.recvQueue), + atomic.LoadInt32(dfq.sendSpace), + atomic.LoadInt32(dfq.reportedSpace), + ) +} + +// RecvQueueLen returns the current length of the receive queue. +func (dfq *DuplexFlowQueue) RecvQueueLen() int { + return len(dfq.recvQueue) +} + +// SendQueueLen returns the current length of the send queue. +func (dfq *DuplexFlowQueue) SendQueueLen() int { + return len(dfq.sendQueue) +} diff --git a/spn/terminal/defaults.go b/spn/terminal/defaults.go new file mode 100644 index 00000000..57f17f47 --- /dev/null +++ b/spn/terminal/defaults.go @@ -0,0 +1,36 @@ +package terminal + +const ( + // UsePriorityDataMsgs defines whether priority data messages should be used. + UsePriorityDataMsgs = true +) + +// DefaultCraneControllerOpts returns the default terminal options for a crane +// controller terminal. +func DefaultCraneControllerOpts() *TerminalOpts { + return &TerminalOpts{ + Padding: 0, // Crane already applies padding. + FlowControl: FlowControlNone, + UsePriorityDataMsgs: UsePriorityDataMsgs, + } +} + +// DefaultHomeHubTerminalOpts returns the default terminal options for a crane +// terminal used for the home hub. +func DefaultHomeHubTerminalOpts() *TerminalOpts { + return &TerminalOpts{ + Padding: 0, // Crane already applies padding. + FlowControl: FlowControlDFQ, + UsePriorityDataMsgs: UsePriorityDataMsgs, + } +} + +// DefaultExpansionTerminalOpts returns the default terminal options for an +// expansion terminal. +func DefaultExpansionTerminalOpts() *TerminalOpts { + return &TerminalOpts{ + Padding: 8, + FlowControl: FlowControlDFQ, + UsePriorityDataMsgs: UsePriorityDataMsgs, + } +} diff --git a/spn/terminal/errors.go b/spn/terminal/errors.go new file mode 100644 index 00000000..619bf181 --- /dev/null +++ b/spn/terminal/errors.go @@ -0,0 +1,221 @@ +package terminal + +import ( + "context" + "errors" + "fmt" + + "github.com/safing/portbase/formats/varint" +) + +// Error is a terminal error. +type Error struct { + // id holds the internal error ID. + id uint8 + // external signifies if the error was received from the outside. + external bool + // err holds the wrapped error or the default error message. + err error +} + +// ID returns the internal ID of the error. +func (e *Error) ID() uint8 { + return e.id +} + +// Error returns the human readable format of the error. +func (e *Error) Error() string { + if e.external { + return "[ext] " + e.err.Error() + } + return e.err.Error() +} + +// IsExternal returns whether the error occurred externally. +func (e *Error) IsExternal() bool { + if e == nil { + return false + } + + return e.external +} + +// Is returns whether the given error is of the same type. +func (e *Error) Is(target error) bool { + if e == nil || target == nil { + return false + } + + t, ok := target.(*Error) //nolint:errorlint // Error implementation, not usage. + if !ok { + return false + } + return e.id == t.id +} + +// Unwrap returns the wrapped error. +func (e *Error) Unwrap() error { + if e == nil || e.err == nil { + return nil + } + return e.err +} + +// With adds context and details where the error occurred. The provided +// message is appended to the error. +// A new error with the same ID is returned and must be compared with +// errors.Is(). +func (e *Error) With(format string, a ...interface{}) *Error { + // Return nil if error is nil. + if e == nil { + return nil + } + + return &Error{ + id: e.id, + err: fmt.Errorf(e.Error()+": "+format, a...), + } +} + +// Wrap adds context higher up in the call chain. The provided message is +// prepended to the error. +// A new error with the same ID is returned and must be compared with +// errors.Is(). +func (e *Error) Wrap(format string, a ...interface{}) *Error { + // Return nil if error is nil. + if e == nil { + return nil + } + + return &Error{ + id: e.id, + err: fmt.Errorf(format+": "+e.Error(), a...), + } +} + +// AsExternal creates and returns an external version of the error. +func (e *Error) AsExternal() *Error { + // Return nil if error is nil. + if e == nil { + return nil + } + + return &Error{ + id: e.id, + err: e.err, + external: true, + } +} + +// Pack returns the serialized internal error ID. The additional message is +// lost and is replaced with the default message upon parsing. +func (e *Error) Pack() []byte { + // Return nil slice if error is nil. + if e == nil { + return nil + } + + return varint.Pack8(e.id) +} + +// ParseExternalError parses an external error. +func ParseExternalError(id []byte) (*Error, error) { + // Return nil for an empty error. + if len(id) == 0 { + return ErrStopping.AsExternal(), nil + } + + parsedID, _, err := varint.Unpack8(id) + if err != nil { + return nil, fmt.Errorf("failed to unpack error ID: %w", err) + } + + return NewExternalError(parsedID), nil +} + +// NewExternalError creates an external error based on the given ID. +func NewExternalError(id uint8) *Error { + err, ok := errorRegistry[id] + if ok { + return err.AsExternal() + } + + return ErrUnknownError.AsExternal() +} + +var errorRegistry = make(map[uint8]*Error) + +func registerError(id uint8, err error) *Error { + // Check for duplicate. + _, ok := errorRegistry[id] + if ok { + panic(fmt.Sprintf("error with id %d already registered", id)) + } + + newErr := &Error{ + id: id, + err: err, + } + + errorRegistry[id] = newErr + return newErr +} + +// func (e *Error) IsSpecial() bool { +// if e == nil { +// return false +// } +// return e.id > 0 && e.id < 8 +// } + +// IsOK returns if the error represents a "OK" or success status. +func (e *Error) IsOK() bool { + return !e.IsError() +} + +// IsError returns if the error represents an erronous condition. +func (e *Error) IsError() bool { + if e == nil || e.err == nil { + return false + } + if e.id == 0 || e.id >= 8 { + return true + } + return false +} + +// Terminal Errors. +var ( + // ErrUnknownError is the default error. + ErrUnknownError = registerError(0, errors.New("unknown error")) + + // Error IDs 1-7 are reserved for special "OK" values. + + ErrStopping = registerError(2, errors.New("stopping")) + ErrExplicitAck = registerError(3, errors.New("explicit ack")) + ErrNoActivity = registerError(4, errors.New("no activity")) + + // Errors IDs 8 and up are for regular errors. + + ErrInternalError = registerError(8, errors.New("internal error")) + ErrMalformedData = registerError(9, errors.New("malformed data")) + ErrUnexpectedMsgType = registerError(10, errors.New("unexpected message type")) + ErrUnknownOperationType = registerError(11, errors.New("unknown operation type")) + ErrUnknownOperationID = registerError(12, errors.New("unknown operation id")) + ErrPermissionDenied = registerError(13, errors.New("permission denied")) + ErrIntegrity = registerError(14, errors.New("integrity violated")) + ErrInvalidOptions = registerError(15, errors.New("invalid options")) + ErrHubNotReady = registerError(16, errors.New("hub not ready")) + ErrRateLimited = registerError(24, errors.New("rate limited")) + ErrIncorrectUsage = registerError(22, errors.New("incorrect usage")) + ErrTimeout = registerError(62, errors.New("timed out")) + ErrUnsupportedVersion = registerError(93, errors.New("unsupported version")) + ErrHubUnavailable = registerError(101, errors.New("hub unavailable")) + ErrAbandonedTerminal = registerError(102, errors.New("terminal is being abandoned")) + ErrShipSunk = registerError(108, errors.New("ship sunk")) + ErrDestinationUnavailable = registerError(113, errors.New("destination unavailable")) + ErrTryAgainLater = registerError(114, errors.New("try again later")) + ErrConnectionError = registerError(121, errors.New("connection error")) + ErrQueueOverflow = registerError(122, errors.New("queue overflowed")) + ErrCanceled = registerError(125, context.Canceled) +) diff --git a/spn/terminal/fmt.go b/spn/terminal/fmt.go new file mode 100644 index 00000000..6bebe3c0 --- /dev/null +++ b/spn/terminal/fmt.go @@ -0,0 +1,27 @@ +package terminal + +import "fmt" + +// CustomTerminalIDFormatting defines an interface for terminal to define their custom ID format. +type CustomTerminalIDFormatting interface { + CustomIDFormat() string +} + +// FmtID formats the terminal ID together with the parent's ID. +func (t *TerminalBase) FmtID() string { + if t.ext != nil { + if customFormatting, ok := t.ext.(CustomTerminalIDFormatting); ok { + return customFormatting.CustomIDFormat() + } + } + + return fmtTerminalID(t.parentID, t.id) +} + +func fmtTerminalID(craneID string, terminalID uint32) string { + return fmt.Sprintf("%s#%d", craneID, terminalID) +} + +func fmtOperationID(craneID string, terminalID, operationID uint32) string { + return fmt.Sprintf("%s#%d>%d", craneID, terminalID, operationID) +} diff --git a/spn/terminal/init.go b/spn/terminal/init.go new file mode 100644 index 00000000..b9960424 --- /dev/null +++ b/spn/terminal/init.go @@ -0,0 +1,210 @@ +package terminal + +import ( + "context" + + "github.com/safing/jess" + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/hub" +) + +/* + +Terminal Init Message Format: + +- Version [varint] +- Data Block [bytes; not blocked] + - TerminalOpts as DSD + +*/ + +const ( + minSupportedTerminalVersion = 1 + maxSupportedTerminalVersion = 1 +) + +// TerminalOpts holds configuration for the terminal. +type TerminalOpts struct { //nolint:golint,maligned // TODO: Rename. + Version uint8 `json:"-"` + Encrypt bool `json:"e,omitempty"` + Padding uint16 `json:"p,omitempty"` + + FlowControl FlowControlType `json:"fc,omitempty"` + FlowControlSize uint32 `json:"qs,omitempty"` // Previously was "QueueSize". + + UsePriorityDataMsgs bool `json:"pr,omitempty"` +} + +// ParseTerminalOpts parses terminal options from the container and checks if +// they are valid. +func ParseTerminalOpts(c *container.Container) (*TerminalOpts, *Error) { + // Parse and check version. + version, err := c.GetNextN8() + if err != nil { + return nil, ErrMalformedData.With("failed to parse version: %w", err) + } + if version < minSupportedTerminalVersion || version > maxSupportedTerminalVersion { + return nil, ErrUnsupportedVersion.With("requested terminal version %d", version) + } + + // Parse init message. + initMsg := &TerminalOpts{} + _, err = dsd.Load(c.CompileData(), initMsg) + if err != nil { + return nil, ErrMalformedData.With("failed to parse init message: %w", err) + } + initMsg.Version = version + + // Check if options are valid. + tErr := initMsg.Check(false) + if tErr != nil { + return nil, tErr + } + + return initMsg, nil +} + +// Pack serialized the terminal options and checks if they are valid. +func (opts *TerminalOpts) Pack() (*container.Container, *Error) { + // Check if options are valid. + tErr := opts.Check(true) + if tErr != nil { + return nil, tErr + } + + // Pack init message. + optsData, err := dsd.Dump(opts, dsd.CBOR) + if err != nil { + return nil, ErrInternalError.With("failed to pack init message: %w", err) + } + + // Compile init message. + return container.New( + varint.Pack8(opts.Version), + optsData, + ), nil +} + +// Check checks if terminal options are valid. +func (opts *TerminalOpts) Check(useDefaultsForRequired bool) *Error { + // Version is required - use default when permitted. + if opts.Version == 0 && useDefaultsForRequired { + opts.Version = 1 + } + if opts.Version < minSupportedTerminalVersion || opts.Version > maxSupportedTerminalVersion { + return ErrInvalidOptions.With("unsupported terminal version %d", opts.Version) + } + + // FlowControl is optional. + switch opts.FlowControl { + case FlowControlDefault: + // Set to default flow control. + opts.FlowControl = defaultFlowControl + case FlowControlNone, FlowControlDFQ: + // Ok. + default: + return ErrInvalidOptions.With("unknown flow control type: %d", opts.FlowControl) + } + + // FlowControlSize is required as it needs to be same on both sides. + // Use default when permitted. + if opts.FlowControlSize == 0 && useDefaultsForRequired { + opts.FlowControlSize = opts.FlowControl.DefaultSize() + } + if opts.FlowControlSize <= 0 || opts.FlowControlSize > MaxQueueSize { + return ErrInvalidOptions.With("invalid flow control size of %d", opts.FlowControlSize) + } + + return nil +} + +// NewLocalBaseTerminal creates a new local terminal base for use with inheriting terminals. +func NewLocalBaseTerminal( + ctx context.Context, + id uint32, + parentID string, + remoteHub *hub.Hub, + initMsg *TerminalOpts, + upstream Upstream, +) ( + t *TerminalBase, + initData *container.Container, + err *Error, +) { + // Pack, check and add defaults to init message. + initData, err = initMsg.Pack() + if err != nil { + return nil, nil, err + } + + // Create baseline. + t, err = createTerminalBase(ctx, id, parentID, false, initMsg, upstream) + if err != nil { + return nil, nil, err + } + + // Setup encryption if enabled. + if remoteHub != nil { + initMsg.Encrypt = true + + // Select signet (public key) of remote Hub to use. + s := remoteHub.SelectSignet() + if s == nil { + return nil, nil, ErrHubNotReady.With("failed to select signet of remote hub") + } + + // Create new session. + env := jess.NewUnconfiguredEnvelope() + env.SuiteID = jess.SuiteWireV1 + env.Recipients = []*jess.Signet{s} + jession, err := env.WireCorrespondence(nil) + if err != nil { + return nil, nil, ErrIntegrity.With("failed to initialize encryption: %w", err) + } + t.jession = jession + + // Encryption is ready for sending. + close(t.encryptionReady) + } + + return t, initData, nil +} + +// NewRemoteBaseTerminal creates a new remote terminal base for use with inheriting terminals. +func NewRemoteBaseTerminal( + ctx context.Context, + id uint32, + parentID string, + identity *cabin.Identity, + initData *container.Container, + upstream Upstream, +) ( + t *TerminalBase, + initMsg *TerminalOpts, + err *Error, +) { + // Parse init message. + initMsg, err = ParseTerminalOpts(initData) + if err != nil { + return nil, nil, err + } + + // Create baseline. + t, err = createTerminalBase(ctx, id, parentID, true, initMsg, upstream) + if err != nil { + return nil, nil, err + } + + // Setup encryption if enabled. + if initMsg.Encrypt { + if identity == nil { + return nil, nil, ErrInternalError.With("missing identity for setting up incoming encryption") + } + t.identity = identity + } + + return t, initMsg, nil +} diff --git a/spn/terminal/metrics.go b/spn/terminal/metrics.go new file mode 100644 index 00000000..0da0c326 --- /dev/null +++ b/spn/terminal/metrics.go @@ -0,0 +1,117 @@ +package terminal + +import ( + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/api" + "github.com/safing/portbase/metrics" +) + +var metricsRegistered = abool.New() + +func registerMetrics() (err error) { + // Only register metrics once. + if !metricsRegistered.SetToIf(false, true) { + return nil + } + + // Get scheduler config and calculat scaling. + schedulerConfig := getSchedulerConfig() + scaleSlotToSecondsFactor := float64(time.Second / schedulerConfig.SlotDuration) + + // Register metrics from scheduler stats. + + _, err = metrics.NewGauge( + "spn/scheduling/unit/slotpace/max", + nil, + metricFromInt(scheduler.GetMaxSlotPace, scaleSlotToSecondsFactor), + &metrics.Options{ + Name: "SPN Scheduling Max Slot Pace (scaled to per second)", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/scheduling/unit/slotpace/leveled/max", + nil, + metricFromInt(scheduler.GetMaxLeveledSlotPace, scaleSlotToSecondsFactor), + &metrics.Options{ + Name: "SPN Scheduling Max Leveled Slot Pace (scaled to per second)", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/scheduling/unit/slotpace/avg", + nil, + metricFromInt(scheduler.GetAvgSlotPace, scaleSlotToSecondsFactor), + &metrics.Options{ + Name: "SPN Scheduling Avg Slot Pace (scaled to per second)", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/scheduling/unit/life/avg/seconds", + nil, + metricFromNanoseconds(scheduler.GetAvgUnitLife), + &metrics.Options{ + Name: "SPN Scheduling Avg Unit Life", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/scheduling/unit/workslot/avg/seconds", + nil, + metricFromNanoseconds(scheduler.GetAvgWorkSlotDuration), + &metrics.Options{ + Name: "SPN Scheduling Avg Work Slot Duration", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + _, err = metrics.NewGauge( + "spn/scheduling/unit/catchupslot/avg/seconds", + nil, + metricFromNanoseconds(scheduler.GetAvgCatchUpSlotDuration), + &metrics.Options{ + Name: "SPN Scheduling Avg Catch-Up Slot Duration", + Permission: api.PermitUser, + }, + ) + if err != nil { + return err + } + + return nil +} + +func metricFromInt(fn func() int64, scaleFactor float64) func() float64 { + return func() float64 { + return float64(fn()) * scaleFactor + } +} + +func metricFromNanoseconds(fn func() int64) func() float64 { + return func() float64 { + return float64(fn()) / float64(time.Second) + } +} diff --git a/spn/terminal/module.go b/spn/terminal/module.go new file mode 100644 index 00000000..178bc08c --- /dev/null +++ b/spn/terminal/module.go @@ -0,0 +1,80 @@ +package terminal + +import ( + "flag" + "time" + + "github.com/safing/portbase/modules" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/conf" + "github.com/safing/portmaster/spn/unit" +) + +var ( + module *modules.Module + rngFeeder *rng.Feeder = rng.NewFeeder() + + scheduler *unit.Scheduler + + debugUnitScheduling bool +) + +func init() { + flag.BoolVar(&debugUnitScheduling, "debug-unit-scheduling", false, "enable debug logs of the SPN unit scheduler") + + module = modules.Register("terminal", nil, start, nil, "base") +} + +func start() error { + rngFeeder = rng.NewFeeder() + + scheduler = unit.NewScheduler(getSchedulerConfig()) + if debugUnitScheduling { + // Debug unit leaks. + scheduler.StartDebugLog() + } + module.StartServiceWorker("msg unit scheduler", 0, scheduler.SlotScheduler) + + lockOpRegistry() + + return registerMetrics() +} + +var waitForever chan time.Time + +// TimedOut returns a channel that triggers when the timeout is reached. +func TimedOut(timeout time.Duration) <-chan time.Time { + if timeout == 0 { + return waitForever + } + return time.After(timeout) +} + +// StopScheduler stops the unit scheduler. +func StopScheduler() { + if scheduler != nil { + scheduler.Stop() + } +} + +func getSchedulerConfig() *unit.SchedulerConfig { + // Client Scheduler Config. + if conf.Client() { + return &unit.SchedulerConfig{ + SlotDuration: 10 * time.Millisecond, // 100 slots per second + MinSlotPace: 10, // 1000pps - Small starting pace for low end devices. + WorkSlotPercentage: 0.9, // 90% + SlotChangeRatePerStreak: 0.1, // 10% - Increase/Decrease quickly. + StatCycleDuration: 1 * time.Minute, // Match metrics report cycle. + } + } + + // Server Scheduler Config. + return &unit.SchedulerConfig{ + SlotDuration: 10 * time.Millisecond, // 100 slots per second + MinSlotPace: 100, // 10000pps - Every server should be able to handle this. + WorkSlotPercentage: 0.7, // 70% + SlotChangeRatePerStreak: 0.05, // 5% + StatCycleDuration: 1 * time.Minute, // Match metrics report cycle. + } +} diff --git a/spn/terminal/module_test.go b/spn/terminal/module_test.go new file mode 100644 index 00000000..1f07003d --- /dev/null +++ b/spn/terminal/module_test.go @@ -0,0 +1,13 @@ +package terminal + +import ( + "testing" + + "github.com/safing/portmaster/service/core/pmtesting" + "github.com/safing/portmaster/spn/conf" +) + +func TestMain(m *testing.M) { + conf.EnablePublicHub(true) + pmtesting.TestMain(m, module) +} diff --git a/spn/terminal/msg.go b/spn/terminal/msg.go new file mode 100644 index 00000000..8ca00489 --- /dev/null +++ b/spn/terminal/msg.go @@ -0,0 +1,106 @@ +package terminal + +import ( + "fmt" + "runtime" + + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/unit" +) + +// Msg is a message within the SPN network stack. +// It includes metadata and unit scheduling. +type Msg struct { + FlowID uint32 + Type MsgType + Data *container.Container + + // Unit scheduling. + // Note: With just 100B per packet, a uint64 (the Unit ID) is enough for + // over 1800 Exabyte. No need for overflow support. + Unit *unit.Unit +} + +// NewMsg returns a new msg. +// The FlowID is unset. +// The Type is Data. +func NewMsg(data []byte) *Msg { + msg := &Msg{ + Type: MsgTypeData, + Data: container.New(data), + Unit: scheduler.NewUnit(), + } + + // Debug unit leaks. + msg.debugWithCaller(2) + + return msg +} + +// NewEmptyMsg returns a new empty msg with an initialized Unit. +// The FlowID is unset. +// The Type is Data. +// The Data is unset. +func NewEmptyMsg() *Msg { + msg := &Msg{ + Type: MsgTypeData, + Unit: scheduler.NewUnit(), + } + + // Debug unit leaks. + msg.debugWithCaller(2) + + return msg +} + +// Pack prepends the message header (Length and ID+Type) to the data. +func (msg *Msg) Pack() { + MakeMsg(msg.Data, msg.FlowID, msg.Type) +} + +// Consume adds another Message to itself. +// The given Msg is packed before adding it to the data. +// The data is moved - not copied! +// High priority mark is inherited. +func (msg *Msg) Consume(other *Msg) { + // Pack message to be added. + other.Pack() + + // Move data. + msg.Data.AppendContainer(other.Data) + + // Inherit high priority. + if other.Unit.IsHighPriority() { + msg.Unit.MakeHighPriority() + } + + // Finish other unit. + other.Finish() +} + +// Finish signals the unit scheduler that this unit has finished processing. +// Will no-op if called on a nil Msg. +func (msg *Msg) Finish() { + // Proxying is necessary, as a nil msg still panics. + if msg == nil { + return + } + msg.Unit.Finish() +} + +// Debug registers the unit for debug output with the given source. +// Additional calls on the same unit update the unit source. +// StartDebugLog() must be called before calling DebugUnit(). +func (msg *Msg) Debug() { + msg.debugWithCaller(2) +} + +func (msg *Msg) debugWithCaller(skip int) { //nolint:unparam + if !debugUnitScheduling || msg == nil { + return + } + _, file, line, ok := runtime.Caller(skip) + if ok { + scheduler.DebugUnit(msg.Unit, fmt.Sprintf("%s:%d", file, line)) + } +} diff --git a/spn/terminal/msgtypes.go b/spn/terminal/msgtypes.go new file mode 100644 index 00000000..df712618 --- /dev/null +++ b/spn/terminal/msgtypes.go @@ -0,0 +1,66 @@ +package terminal + +import ( + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/varint" +) + +/* +Terminal and Operation Message Format: + +- Length [varint] + - If Length is 0, the remainder of given data is padding. +- IDType [varint] + - Type [uses least two significant bits] + - One of Init, Data, Stop + - ID [uses all other bits] + - The ID is currently not adapted in order to make reading raw message + easier. This means that IDs are currently always a multiple of 4. +- Data [bytes; format depends on msg type] + - MsgTypeInit: + - Data [bytes] + - MsgTypeData: + - AddAvailableSpace [varint, if Flow Queue is used] + - (Encrypted) Data [bytes] + - MsgTypeStop: + - Error Code [varint] +*/ + +// MsgType is the message type for both terminals and operations. +type MsgType uint8 + +const ( + // MsgTypeInit is used to establish a new terminal or run a new operation. + MsgTypeInit MsgType = 1 + + // MsgTypeData is used to send data to a terminal or operation. + MsgTypeData MsgType = 2 + + // MsgTypePriorityData is used to send prioritized data to a terminal or operation. + MsgTypePriorityData MsgType = 0 + + // MsgTypeStop is used to abandon a terminal or end an operation, with an optional error. + MsgTypeStop MsgType = 3 +) + +// AddIDType prepends the ID and Type header to the message. +func AddIDType(c *container.Container, id uint32, msgType MsgType) { + c.Prepend(varint.Pack32(id | uint32(msgType))) +} + +// MakeMsg prepends the message header (Length and ID+Type) to the data. +func MakeMsg(c *container.Container, id uint32, msgType MsgType) { + AddIDType(c, id, msgType) + c.PrependLength() +} + +// ParseIDType parses the combined message ID and type. +func ParseIDType(c *container.Container) (id uint32, msgType MsgType, err error) { + idType, err := c.GetNextN32() + if err != nil { + return 0, 0, err + } + + msgType = MsgType(idType % 4) + return idType - uint32(msgType), msgType, nil +} diff --git a/spn/terminal/operation.go b/spn/terminal/operation.go new file mode 100644 index 00000000..100936ec --- /dev/null +++ b/spn/terminal/operation.go @@ -0,0 +1,332 @@ +package terminal + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/tevino/abool" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/log" + "github.com/safing/portbase/utils" +) + +// Operation is an interface for all operations. +type Operation interface { + // InitOperationBase initialize the operation with the ID and attached terminal. + // Should not be overridden by implementations. + InitOperationBase(t Terminal, opID uint32) + + // ID returns the ID of the operation. + // Should not be overridden by implementations. + ID() uint32 + + // Type returns the operation's type ID. + // Should be overridden by implementations to return correct type ID. + Type() string + + // Deliver delivers a message to the operation. + // Meant to be overridden by implementations. + Deliver(msg *Msg) *Error + + // NewMsg creates a new message from this operation. + // Should not be overridden by implementations. + NewMsg(data []byte) *Msg + + // Send sends a message to the other side. + // Should not be overridden by implementations. + Send(msg *Msg, timeout time.Duration) *Error + + // Flush sends all messages waiting in the terminal. + // Should not be overridden by implementations. + Flush(timeout time.Duration) + + // Stopped returns whether the operation has stopped. + // Should not be overridden by implementations. + Stopped() bool + + // markStopped marks the operation as stopped. + // It returns whether the stop flag was set. + markStopped() bool + + // Stop stops the operation by unregistering it from the terminal and calling HandleStop(). + // Should not be overridden by implementations. + Stop(self Operation, err *Error) + + // HandleStop gives the operation the ability to cleanly shut down. + // The returned error is the error to send to the other side. + // Should never be called directly. Call Stop() instead. + // Meant to be overridden by implementations. + HandleStop(err *Error) (errorToSend *Error) + + // Terminal returns the terminal the operation is linked to. + // Should not be overridden by implementations. + Terminal() Terminal +} + +// OperationFactory defines an operation factory. +type OperationFactory struct { + // Type is the type id of an operation. + Type string + // Requires defines the required permissions to run an operation. + Requires Permission + // Start is the function that starts a new operation. + Start OperationStarter +} + +// OperationStarter is used to initialize operations remotely. +type OperationStarter func(attachedTerminal Terminal, opID uint32, initData *container.Container) (Operation, *Error) + +var ( + opRegistry = make(map[string]*OperationFactory) + opRegistryLock sync.Mutex + opRegistryLocked = abool.New() +) + +// RegisterOpType registers a new operation type and may only be called during +// Go's init and a module's prep phase. +func RegisterOpType(factory OperationFactory) { + // Check if we can still register an operation type. + if opRegistryLocked.IsSet() { + log.Errorf("spn/terminal: failed to register operation %s: operation registry is already locked", factory.Type) + return + } + + opRegistryLock.Lock() + defer opRegistryLock.Unlock() + + // Check if the operation type was already registered. + if _, ok := opRegistry[factory.Type]; ok { + log.Errorf("spn/terminal: failed to register operation type %s: type already registered", factory.Type) + return + } + + // Save to registry. + opRegistry[factory.Type] = &factory +} + +func lockOpRegistry() { + opRegistryLocked.Set() +} + +func (t *TerminalBase) handleOperationStart(opID uint32, initData *container.Container) { + // Check if the terminal is being abandoned. + if t.Abandoning.IsSet() { + t.StopOperation(newUnknownOp(opID, ""), ErrAbandonedTerminal) + return + } + + // Extract the requested operation name. + opType, err := initData.GetNextBlock() + if err != nil { + t.StopOperation(newUnknownOp(opID, ""), ErrMalformedData.With("failed to get init data: %w", err)) + return + } + + // Get the operation factory from the registry. + factory, ok := opRegistry[string(opType)] + if !ok { + t.StopOperation(newUnknownOp(opID, ""), ErrUnknownOperationType.With(utils.SafeFirst16Bytes(opType))) + return + } + + // Check if the Terminal has the required permission to run the operation. + if !t.HasPermission(factory.Requires) { + t.StopOperation(newUnknownOp(opID, factory.Type), ErrPermissionDenied) + return + } + + // Get terminal to attach to. + attachToTerminal := t.ext + if attachToTerminal == nil { + attachToTerminal = t + } + + // Run the operation. + op, opErr := factory.Start(attachToTerminal, opID, initData) + switch { + case opErr != nil: + // Something went wrong. + t.StopOperation(newUnknownOp(opID, factory.Type), opErr) + case op == nil: + // The Operation was successful and is done already. + log.Debugf("spn/terminal: operation %s %s executed", factory.Type, fmtOperationID(t.parentID, t.id, opID)) + t.StopOperation(newUnknownOp(opID, factory.Type), nil) + default: + // The operation started successfully and requires persistence. + t.SetActiveOp(opID, op) + log.Debugf("spn/terminal: operation %s %s started", factory.Type, fmtOperationID(t.parentID, t.id, opID)) + } +} + +// StartOperation starts the given operation by assigning it an ID and sending the given operation initialization data. +func (t *TerminalBase) StartOperation(op Operation, initData *container.Container, timeout time.Duration) *Error { + // Get terminal to attach to. + attachToTerminal := t.ext + if attachToTerminal == nil { + attachToTerminal = t + } + + // Get the next operation ID and set it on the operation with the terminal. + op.InitOperationBase(attachToTerminal, atomic.AddUint32(t.nextOpID, 8)) + + // Always add operation to the active operations, as we need to receive a + // reply in any case. + t.SetActiveOp(op.ID(), op) + + log.Debugf("spn/terminal: operation %s %s started", op.Type(), fmtOperationID(t.parentID, t.id, op.ID())) + + // Add or create the operation type block. + if initData == nil { + initData = container.New() + initData.AppendAsBlock([]byte(op.Type())) + } else { + initData.PrependAsBlock([]byte(op.Type())) + } + + // Create init msg. + msg := NewEmptyMsg() + msg.FlowID = op.ID() + msg.Type = MsgTypeInit + msg.Data = initData + msg.Unit.MakeHighPriority() + + // Send init msg. + err := op.Send(msg, timeout) + if err != nil { + msg.Finish() + } + return err +} + +// Send sends data via this terminal. +// If a timeout is set, sending will fail after the given timeout passed. +func (t *TerminalBase) Send(msg *Msg, timeout time.Duration) *Error { + // Wait for processing slot. + msg.Unit.WaitForSlot() + + // Check if the send queue has available space. + select { + case t.sendQueue <- msg: + return nil + default: + } + + // Submit message to buffer, if space is available. + select { + case t.sendQueue <- msg: + return nil + case <-TimedOut(timeout): + msg.Finish() + return ErrTimeout.With("sending via terminal") + case <-t.Ctx().Done(): + msg.Finish() + return ErrStopping + } +} + +// StopOperation sends the end signal with an optional error and then deletes +// the operation from the Terminal state and calls HandleStop() on the Operation. +func (t *TerminalBase) StopOperation(op Operation, err *Error) { + // Check if the operation has already stopped. + if !op.markStopped() { + return + } + + // Log reason the Operation is ending. Override stopping error with nil. + switch { + case err == nil: + log.Debugf("spn/terminal: operation %s %s stopped", op.Type(), fmtOperationID(t.parentID, t.id, op.ID())) + case err.IsOK(), err.Is(ErrTryAgainLater), err.Is(ErrRateLimited): + log.Debugf("spn/terminal: operation %s %s stopped: %s", op.Type(), fmtOperationID(t.parentID, t.id, op.ID()), err) + default: + log.Warningf("spn/terminal: operation %s %s failed: %s", op.Type(), fmtOperationID(t.parentID, t.id, op.ID()), err) + } + + module.StartWorker("stop operation", func(_ context.Context) error { + // Call operation stop handle function for proper shutdown cleaning up. + err = op.HandleStop(err) + + // Send error to the connected Operation, if the error is internal. + if !err.IsExternal() { + if err == nil { + err = ErrStopping + } + + msg := NewMsg(err.Pack()) + msg.FlowID = op.ID() + msg.Type = MsgTypeStop + + tErr := t.Send(msg, 10*time.Second) + if tErr != nil { + msg.Finish() + log.Warningf("spn/terminal: failed to send stop msg: %s", tErr) + } + } + + // Remove operation from terminal. + t.DeleteActiveOp(op.ID()) + + return nil + }) +} + +// GetActiveOp returns the active operation with the given ID from the +// Terminal state. +func (t *TerminalBase) GetActiveOp(opID uint32) (op Operation, ok bool) { + t.lock.RLock() + defer t.lock.RUnlock() + + op, ok = t.operations[opID] + return +} + +// SetActiveOp saves an active operation to the Terminal state. +func (t *TerminalBase) SetActiveOp(opID uint32, op Operation) { + t.lock.Lock() + defer t.lock.Unlock() + + t.operations[opID] = op +} + +// DeleteActiveOp deletes an active operation from the Terminal state. +func (t *TerminalBase) DeleteActiveOp(opID uint32) { + t.lock.Lock() + defer t.lock.Unlock() + + delete(t.operations, opID) +} + +// GetActiveOpCount returns the amount of active operations. +func (t *TerminalBase) GetActiveOpCount() int { + t.lock.RLock() + defer t.lock.RUnlock() + + return len(t.operations) +} + +func newUnknownOp(id uint32, typeID string) *unknownOp { + op := &unknownOp{ + typeID: typeID, + } + op.id = id + return op +} + +type unknownOp struct { + OperationBase + typeID string +} + +func (op *unknownOp) Type() string { + if op.typeID != "" { + return op.typeID + } + return "unknown" +} + +func (op *unknownOp) Deliver(msg *Msg) *Error { + return ErrIncorrectUsage.With("unknown op shim cannot receive") +} diff --git a/spn/terminal/operation_base.go b/spn/terminal/operation_base.go new file mode 100644 index 00000000..4b588c4f --- /dev/null +++ b/spn/terminal/operation_base.go @@ -0,0 +1,185 @@ +package terminal + +import ( + "time" + + "github.com/tevino/abool" +) + +// OperationBase provides the basic operation functionality. +type OperationBase struct { + terminal Terminal + id uint32 + stopped abool.AtomicBool +} + +// InitOperationBase initialize the operation with the ID and attached terminal. +// Should not be overridden by implementations. +func (op *OperationBase) InitOperationBase(t Terminal, opID uint32) { + op.id = opID + op.terminal = t +} + +// ID returns the ID of the operation. +// Should not be overridden by implementations. +func (op *OperationBase) ID() uint32 { + return op.id +} + +// Type returns the operation's type ID. +// Should be overridden by implementations to return correct type ID. +func (op *OperationBase) Type() string { + return "unknown" +} + +// Deliver delivers a message to the operation. +// Meant to be overridden by implementations. +func (op *OperationBase) Deliver(_ *Msg) *Error { + return ErrIncorrectUsage.With("Deliver not implemented for this operation") +} + +// NewMsg creates a new message from this operation. +// Should not be overridden by implementations. +func (op *OperationBase) NewMsg(data []byte) *Msg { + msg := NewMsg(data) + msg.FlowID = op.id + msg.Type = MsgTypeData + + // Debug unit leaks. + msg.debugWithCaller(2) + + return msg +} + +// NewEmptyMsg creates a new empty message from this operation. +// Should not be overridden by implementations. +func (op *OperationBase) NewEmptyMsg() *Msg { + msg := NewEmptyMsg() + msg.FlowID = op.id + msg.Type = MsgTypeData + + // Debug unit leaks. + msg.debugWithCaller(2) + + return msg +} + +// Send sends a message to the other side. +// Should not be overridden by implementations. +func (op *OperationBase) Send(msg *Msg, timeout time.Duration) *Error { + // Add and update metadata. + msg.FlowID = op.id + if msg.Type == MsgTypeData && msg.Unit.IsHighPriority() && UsePriorityDataMsgs { + msg.Type = MsgTypePriorityData + } + + // Wait for processing slot. + msg.Unit.WaitForSlot() + + // Send message. + tErr := op.terminal.Send(msg, timeout) + if tErr != nil { + // Finish message unit on failure. + msg.Finish() + } + return tErr +} + +// Flush sends all messages waiting in the terminal. +// Meant to be overridden by implementations. +func (op *OperationBase) Flush(timeout time.Duration) { + op.terminal.Flush(timeout) +} + +// Stopped returns whether the operation has stopped. +// Should not be overridden by implementations. +func (op *OperationBase) Stopped() bool { + return op.stopped.IsSet() +} + +// markStopped marks the operation as stopped. +// It returns whether the stop flag was set. +func (op *OperationBase) markStopped() bool { + return op.stopped.SetToIf(false, true) +} + +// Stop stops the operation by unregistering it from the terminal and calling HandleStop(). +// Should not be overridden by implementations. +func (op *OperationBase) Stop(self Operation, err *Error) { + // Stop operation from terminal. + op.terminal.StopOperation(self, err) +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +// Meant to be overridden by implementations. +func (op *OperationBase) HandleStop(err *Error) (errorToSend *Error) { + return err +} + +// Terminal returns the terminal the operation is linked to. +// Should not be overridden by implementations. +func (op *OperationBase) Terminal() Terminal { + return op.terminal +} + +// OneOffOperationBase is an operation base for operations that just have one +// message and a error return. +type OneOffOperationBase struct { + OperationBase + + Result chan *Error +} + +// Init initializes the single operation base. +func (op *OneOffOperationBase) Init() { + op.Result = make(chan *Error, 1) +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *OneOffOperationBase) HandleStop(err *Error) (errorToSend *Error) { + select { + case op.Result <- err: + default: + } + return err +} + +// MessageStreamOperationBase is an operation base for receiving a message stream. +// Every received message must be finished by the implementing operation. +type MessageStreamOperationBase struct { + OperationBase + + Delivered chan *Msg + Ended chan *Error +} + +// Init initializes the operation base. +func (op *MessageStreamOperationBase) Init(deliverQueueSize int) { + op.Delivered = make(chan *Msg, deliverQueueSize) + op.Ended = make(chan *Error, 1) +} + +// Deliver delivers data to the operation. +func (op *MessageStreamOperationBase) Deliver(msg *Msg) *Error { + select { + case op.Delivered <- msg: + return nil + default: + return ErrIncorrectUsage.With("request was not waiting for data") + } +} + +// HandleStop gives the operation the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Stop() instead. +func (op *MessageStreamOperationBase) HandleStop(err *Error) (errorToSend *Error) { + select { + case op.Ended <- err: + default: + } + return err +} diff --git a/spn/terminal/operation_counter.go b/spn/terminal/operation_counter.go new file mode 100644 index 00000000..59d175e0 --- /dev/null +++ b/spn/terminal/operation_counter.go @@ -0,0 +1,255 @@ +package terminal + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/formats/varint" + "github.com/safing/portbase/log" +) + +// CounterOpType is the type ID for the Counter Operation. +const CounterOpType string = "debug/count" + +// CounterOp sends increasing numbers on both sides. +type CounterOp struct { //nolint:maligned + OperationBase + + wg sync.WaitGroup + server bool + opts *CounterOpts + + counterLock sync.Mutex + ClientCounter uint64 + ServerCounter uint64 + Error error +} + +// CounterOpts holds the options for CounterOp. +type CounterOpts struct { + ClientCountTo uint64 + ServerCountTo uint64 + Wait time.Duration + Flush bool + + suppressWorker bool +} + +func init() { + RegisterOpType(OperationFactory{ + Type: CounterOpType, + Start: startCounterOp, + }) +} + +// NewCounterOp returns a new CounterOp. +func NewCounterOp(t Terminal, opts CounterOpts) (*CounterOp, *Error) { + // Create operation. + op := &CounterOp{ + opts: &opts, + } + op.wg.Add(1) + + // Create argument container. + data, err := dsd.Dump(op.opts, dsd.JSON) + if err != nil { + return nil, ErrInternalError.With("failed to pack options: %w", err) + } + + // Initialize operation. + tErr := t.StartOperation(op, container.New(data), 3*time.Second) + if tErr != nil { + return nil, tErr + } + + // Start worker if needed. + if op.getRemoteCounterTarget() > 0 && !op.opts.suppressWorker { + module.StartWorker("counter sender", op.CounterWorker) + } + return op, nil +} + +func startCounterOp(t Terminal, opID uint32, data *container.Container) (Operation, *Error) { + // Create operation. + op := &CounterOp{ + server: true, + } + op.InitOperationBase(t, opID) + op.wg.Add(1) + + // Parse arguments. + opts := &CounterOpts{} + _, err := dsd.Load(data.CompileData(), opts) + if err != nil { + return nil, ErrInternalError.With("failed to unpack options: %w", err) + } + op.opts = opts + + // Start worker if needed. + if op.getRemoteCounterTarget() > 0 { + module.StartWorker("counter sender", op.CounterWorker) + } + + return op, nil +} + +// Type returns the operation's type ID. +func (op *CounterOp) Type() string { + return CounterOpType +} + +func (op *CounterOp) getCounter(sending, increase bool) uint64 { + op.counterLock.Lock() + defer op.counterLock.Unlock() + + // Use server counter, when op is server or for sending, but not when both. + if op.server != sending { + if increase { + op.ServerCounter++ + } + return op.ServerCounter + } + + if increase { + op.ClientCounter++ + } + return op.ClientCounter +} + +func (op *CounterOp) getRemoteCounterTarget() uint64 { + if op.server { + return op.opts.ClientCountTo + } + return op.opts.ServerCountTo +} + +func (op *CounterOp) isDone() bool { + op.counterLock.Lock() + defer op.counterLock.Unlock() + + return op.ClientCounter >= op.opts.ClientCountTo && + op.ServerCounter >= op.opts.ServerCountTo +} + +// Deliver delivers data to the operation. +func (op *CounterOp) Deliver(msg *Msg) *Error { + defer msg.Finish() + + nextStep, err := msg.Data.GetNextN64() + if err != nil { + op.Stop(op, ErrMalformedData.With("failed to parse next number: %w", err)) + return nil + } + + // Count and compare. + counter := op.getCounter(false, true) + + // Debugging: + // if counter < 100 || + // counter < 1000 && counter%100 == 0 || + // counter < 10000 && counter%1000 == 0 || + // counter < 100000 && counter%10000 == 0 || + // counter < 1000000 && counter%100000 == 0 { + // log.Errorf("spn/terminal: counter %s>%d recvd, now at %d", op.t.FmtID(), op.id, counter) + // } + + if counter != nextStep { + log.Warningf( + "terminal: integrity of counter op violated: received %d, expected %d", + nextStep, + counter, + ) + op.Stop(op, ErrIntegrity.With("counters mismatched")) + return nil + } + + // Check if we are done. + if op.isDone() { + op.Stop(op, nil) + } + + return nil +} + +// HandleStop handles stopping the operation. +func (op *CounterOp) HandleStop(err *Error) (errorToSend *Error) { + // Check if counting finished. + if !op.isDone() { + err := fmt.Errorf( + "counter op %d: did not finish counting (%d<-%d %d->%d)", + op.id, + op.opts.ClientCountTo, op.ClientCounter, + op.ServerCounter, op.opts.ServerCountTo, + ) + op.Error = err + } + + op.wg.Done() + return err +} + +// SendCounter sends the next counter. +func (op *CounterOp) SendCounter() *Error { + if op.Stopped() { + return ErrStopping + } + + // Increase sending counter. + counter := op.getCounter(true, true) + + // Debugging: + // if counter < 100 || + // counter < 1000 && counter%100 == 0 || + // counter < 10000 && counter%1000 == 0 || + // counter < 100000 && counter%10000 == 0 || + // counter < 1000000 && counter%100000 == 0 { + // defer log.Errorf("spn/terminal: counter %s>%d sent, now at %d", op.t.FmtID(), op.id, counter) + // } + + return op.Send(op.NewMsg(varint.Pack64(counter)), 3*time.Second) +} + +// Wait waits for the Counter Op to finish. +func (op *CounterOp) Wait() { + op.wg.Wait() +} + +// CounterWorker is a worker that sends counters. +func (op *CounterOp) CounterWorker(ctx context.Context) error { + for { + // Send counter msg. + err := op.SendCounter() + switch err { + case nil: + // All good, continue. + case ErrStopping: + // Done! + return nil + default: + // Something went wrong. + err := fmt.Errorf("counter op %d: failed to send counter: %w", op.id, err) + op.Error = err + op.Stop(op, ErrInternalError.With(err.Error())) + return nil + } + + // Maybe flush message. + if op.opts.Flush { + op.terminal.Flush(1 * time.Second) + } + + // Check if we are done with sending. + if op.getCounter(true, false) >= op.getRemoteCounterTarget() { + return nil + } + + // Maybe wait a little. + if op.opts.Wait > 0 { + time.Sleep(op.opts.Wait) + } + } +} diff --git a/spn/terminal/permission.go b/spn/terminal/permission.go new file mode 100644 index 00000000..ee39e28a --- /dev/null +++ b/spn/terminal/permission.go @@ -0,0 +1,50 @@ +package terminal + +// Permission is a bit-map of granted permissions. +type Permission uint16 + +// Permissions. +const ( + NoPermission Permission = 0x0 + MayExpand Permission = 0x1 + MayConnect Permission = 0x2 + IsHubOwner Permission = 0x100 + IsHubAdvisor Permission = 0x200 + IsCraneController Permission = 0x8000 +) + +// AuthorizingTerminal is an interface for terminals that support authorization. +type AuthorizingTerminal interface { + GrantPermission(grant Permission) + HasPermission(required Permission) bool +} + +// GrantPermission grants the specified permissions to the Terminal. +func (t *TerminalBase) GrantPermission(grant Permission) { + t.lock.Lock() + defer t.lock.Unlock() + + t.permission |= grant +} + +// HasPermission returns if the Terminal has the specified permission. +func (t *TerminalBase) HasPermission(required Permission) bool { + t.lock.RLock() + defer t.lock.RUnlock() + + return t.permission.Has(required) +} + +// Has returns if the permission includes the specified permission. +func (p Permission) Has(required Permission) bool { + return p&required == required +} + +// AddPermissions combines multiple permissions. +func AddPermissions(perms ...Permission) Permission { + var all Permission + for _, p := range perms { + all |= p + } + return all +} diff --git a/spn/terminal/rate_limit.go b/spn/terminal/rate_limit.go new file mode 100644 index 00000000..162afca0 --- /dev/null +++ b/spn/terminal/rate_limit.go @@ -0,0 +1,39 @@ +package terminal + +import "time" + +// RateLimiter is a data flow rate limiter. +type RateLimiter struct { + maxBytesPerSlot uint64 + slotBytes uint64 + slotStarted time.Time +} + +// NewRateLimiter returns a new rate limiter. +// The given MBit/s are transformed to bytes, so giving a multiple of 8 is +// advised for accurate results. +func NewRateLimiter(mbits uint64) *RateLimiter { + return &RateLimiter{ + maxBytesPerSlot: (mbits / 8) * 1_000_000, + slotStarted: time.Now(), + } +} + +// Limit is given the current transferred bytes and blocks until they may be sent. +func (rl *RateLimiter) Limit(xferBytes uint64) { + // Check if we need to limit transfer if we go over to max bytes per slot. + if rl.slotBytes > rl.maxBytesPerSlot { + // Wait if we are still within the slot. + sinceSlotStart := time.Since(rl.slotStarted) + if sinceSlotStart < time.Second { + time.Sleep(time.Second - sinceSlotStart) + } + + // Reset state for next slot. + rl.slotBytes = 0 + rl.slotStarted = time.Now() + } + + // Add new bytes after checking, as first step over the limit is fully using the limit. + rl.slotBytes += xferBytes +} diff --git a/spn/terminal/session.go b/spn/terminal/session.go new file mode 100644 index 00000000..fa2d1695 --- /dev/null +++ b/spn/terminal/session.go @@ -0,0 +1,166 @@ +package terminal + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/safing/portbase/log" +) + +const ( + rateLimitMinOps = 250 + rateLimitMaxOpsPerSecond = 5 + + rateLimitMinSuspicion = 25 + rateLimitMinPermaSuspicion = rateLimitMinSuspicion * 100 + rateLimitMaxSuspicionPerSecond = 1 + + // Make this big enough to trigger suspicion limit in first blast. + concurrencyPoolSize = 30 +) + +// Session holds terminal metadata for operations. +type Session struct { + sync.RWMutex + + // Rate Limiting. + + // started holds the unix timestamp in seconds when the session was started. + // It is set when the Session is created and may be treated as a constant. + started int64 + + // opCount is the amount of operations started (and not rate limited by suspicion). + opCount atomic.Int64 + + // suspicionScore holds a score of suspicious activity. + // Every suspicious operations is counted as at least 1. + // Rate limited operations because of suspicion are also counted as 1. + suspicionScore atomic.Int64 + + concurrencyPool chan struct{} +} + +// SessionTerminal is an interface for terminals that support authorization. +type SessionTerminal interface { + GetSession() *Session +} + +// SessionAddOn can be inherited by terminals to add support for sessions. +type SessionAddOn struct { + lock sync.Mutex + + // session holds the terminal session. + session *Session +} + +// GetSession returns the terminal's session. +func (t *SessionAddOn) GetSession() *Session { + t.lock.Lock() + defer t.lock.Unlock() + + // Create session if it does not exist. + if t.session == nil { + t.session = NewSession() + } + + return t.session +} + +// NewSession returns a new session. +func NewSession() *Session { + return &Session{ + started: time.Now().Unix() - 1, // Ensure a 1 second difference to current time. + concurrencyPool: make(chan struct{}, concurrencyPoolSize), + } +} + +// RateLimitInfo returns some basic information about the status of the rate limiter. +func (s *Session) RateLimitInfo() string { + secondsActive := time.Now().Unix() - s.started + + return fmt.Sprintf( + "%do/s %ds/s %ds", + s.opCount.Load()/secondsActive, + s.suspicionScore.Load()/secondsActive, + secondsActive, + ) +} + +// RateLimit enforces a rate and suspicion limit. +func (s *Session) RateLimit() *Error { + secondsActive := time.Now().Unix() - s.started + + // Check the suspicion limit. + score := s.suspicionScore.Load() + if score > rateLimitMinSuspicion { + scorePerSecond := score / secondsActive + if scorePerSecond >= rateLimitMaxSuspicionPerSecond { + // Add current try to suspicion score. + s.suspicionScore.Add(1) + + return ErrRateLimited + } + + // Permanently rate limit if suspicion goes over the perma min limit and + // the suspicion score is greater than 80% of the operation count. + if score > rateLimitMinPermaSuspicion && + score*5 > s.opCount.Load()*4 { // Think: 80*5 == 100*4 + return ErrRateLimited + } + } + + // Check the rate limit. + count := s.opCount.Add(1) + if count > rateLimitMinOps { + opsPerSecond := count / secondsActive + if opsPerSecond >= rateLimitMaxOpsPerSecond { + return ErrRateLimited + } + } + + return nil +} + +// Suspicion Factors. +const ( + SusFactorCommon = 1 + SusFactorWeirdButOK = 5 + SusFactorQuiteUnusual = 10 + SusFactorMustBeMalicious = 100 +) + +// ReportSuspiciousActivity reports suspicious activity of the terminal. +func (s *Session) ReportSuspiciousActivity(factor int64) { + s.suspicionScore.Add(factor) +} + +// LimitConcurrency limits concurrent executions. +// If over the limit, waiting goroutines are selected randomly. +// It returns the context error if it was canceled. +func (s *Session) LimitConcurrency(ctx context.Context, f func()) error { + // Wait for place in pool. + select { + case <-ctx.Done(): + return ctx.Err() + case s.concurrencyPool <- struct{}{}: + // We added our entry to the pool, continue with execution. + } + + // Drain own spot if pool after execution. + defer func() { + select { + case <-s.concurrencyPool: + // Own entry drained. + default: + // This should never happen, but let's play safe and not deadlock when pool is empty. + log.Warningf("spn/session: failed to drain own entry from concurrency pool") + } + }() + + // Execute and return. + f() + return nil +} diff --git a/spn/terminal/session_test.go b/spn/terminal/session_test.go new file mode 100644 index 00000000..e61d1f52 --- /dev/null +++ b/spn/terminal/session_test.go @@ -0,0 +1,94 @@ +package terminal + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRateLimit(t *testing.T) { + t.Parallel() + + var tErr *Error + s := NewSession() + + // Everything should be okay within the min limit. + for i := 0; i < rateLimitMinOps; i++ { + tErr = s.RateLimit() + if tErr != nil { + t.Error("should not rate limit within min limit") + } + } + + // Somewhere here we should rate limiting. + for i := 0; i < rateLimitMaxOpsPerSecond; i++ { + tErr = s.RateLimit() + } + assert.ErrorIs(t, tErr, ErrRateLimited, "should rate limit") +} + +func TestSuspicionLimit(t *testing.T) { + t.Parallel() + + var tErr *Error + s := NewSession() + + // Everything should be okay within the min limit. + for i := 0; i < rateLimitMinSuspicion; i++ { + tErr = s.RateLimit() + if tErr != nil { + t.Error("should not rate limit within min limit") + } + s.ReportSuspiciousActivity(SusFactorCommon) + } + + // Somewhere here we should rate limiting. + for i := 0; i < rateLimitMaxSuspicionPerSecond; i++ { + s.ReportSuspiciousActivity(SusFactorCommon) + tErr = s.RateLimit() + } + if tErr == nil { + t.Error("should rate limit") + } +} + +func TestConcurrencyLimit(t *testing.T) { + t.Parallel() + + s := NewSession() + started := time.Now() + wg := sync.WaitGroup{} + workTime := 1 * time.Millisecond + workers := concurrencyPoolSize * 10 + + // Start many workers to test concurrency. + wg.Add(workers) + for i := 0; i < workers; i++ { + workerNum := i + go func() { + defer func() { + _ = recover() + }() + _ = s.LimitConcurrency(context.Background(), func() { + time.Sleep(workTime) + wg.Done() + + // Panic sometimes. + if workerNum%concurrencyPoolSize == 0 { + panic("test") + } + }) + }() + } + + // Wait and check time needed. + wg.Wait() + if time.Since(started) < (time.Duration(workers) * workTime / concurrencyPoolSize) { + t.Errorf("workers were too quick - only took %s", time.Since(started)) + } else { + t.Logf("workers were correctly limited - took %s", time.Since(started)) + } +} diff --git a/spn/terminal/terminal.go b/spn/terminal/terminal.go new file mode 100644 index 00000000..bbccad2f --- /dev/null +++ b/spn/terminal/terminal.go @@ -0,0 +1,909 @@ +package terminal + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/tevino/abool" + + "github.com/safing/jess" + "github.com/safing/portbase/container" + "github.com/safing/portbase/log" + "github.com/safing/portbase/modules" + "github.com/safing/portbase/rng" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/conf" +) + +const ( + timeoutTicks = 5 + + clientTerminalAbandonTimeout = 15 * time.Second + serverTerminalAbandonTimeout = 5 * time.Minute +) + +// Terminal represents a terminal. +type Terminal interface { //nolint:golint // Being explicit is helpful here. + // ID returns the terminal ID. + ID() uint32 + // Ctx returns the terminal context. + Ctx() context.Context + + // Deliver delivers a message to the terminal. + // Should not be overridden by implementations. + Deliver(msg *Msg) *Error + // Send is used by others to send a message through the terminal. + // Should not be overridden by implementations. + Send(msg *Msg, timeout time.Duration) *Error + // Flush sends all messages waiting in the terminal. + // Should not be overridden by implementations. + Flush(timeout time.Duration) + + // StartOperation starts the given operation by assigning it an ID and sending the given operation initialization data. + // Should not be overridden by implementations. + StartOperation(op Operation, initData *container.Container, timeout time.Duration) *Error + // StopOperation stops the given operation. + // Should not be overridden by implementations. + StopOperation(op Operation, err *Error) + + // Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon(). + // Should not be overridden by implementations. + Abandon(err *Error) + // HandleAbandon gives the terminal the ability to cleanly shut down. + // The terminal is still fully functional at this point. + // The returned error is the error to send to the other side. + // Should never be called directly. Call Abandon() instead. + // Meant to be overridden by implementations. + HandleAbandon(err *Error) (errorToSend *Error) + // HandleDestruction gives the terminal the ability to clean up. + // The terminal has already fully shut down at this point. + // Should never be called directly. Call Abandon() instead. + // Meant to be overridden by implementations. + HandleDestruction(err *Error) + + // FmtID formats the terminal ID (including parent IDs). + // May be overridden by implementations. + FmtID() string +} + +// TerminalBase contains the basic functions of a terminal. +type TerminalBase struct { //nolint:golint,maligned // Being explicit is helpful here. + // TODO: Fix maligned. + Terminal // Interface check. + + lock sync.RWMutex + + // id is the underlying id of the Terminal. + id uint32 + // parentID is the id of the parent component. + parentID string + + // ext holds the extended terminal so that the base terminal can access custom functions. + ext Terminal + // sendQueue holds message to be sent. + sendQueue chan *Msg + // flowControl holds the flow control system. + flowControl FlowControl + // upstream represents the upstream (parent) terminal. + upstream Upstream + + // deliverProxy is populated with the configured deliver function + deliverProxy func(msg *Msg) *Error + // recvProxy is populated with the configured recv function + recvProxy func() <-chan *Msg + + // ctx is the context of the Terminal. + ctx context.Context + // cancelCtx cancels ctx. + cancelCtx context.CancelFunc + + // waitForFlush signifies if sending should be delayed until the next call + // to Flush() + waitForFlush *abool.AtomicBool + // flush is used to send a finish function to the handler, which will write + // all pending messages and then call the received function. + flush chan func() + // idleTicker ticks for increasing and checking the idle counter. + idleTicker *time.Ticker + // idleCounter counts the ticks the terminal has been idle. + idleCounter *uint32 + + // jession is the jess session used for encryption. + jession *jess.Session + // jessionLock locks jession. + jessionLock sync.Mutex + // encryptionReady is set when the encryption is ready for sending messages. + encryptionReady chan struct{} + // identity is the identity used by a remote Terminal. + identity *cabin.Identity + + // operations holds references to all active operations that require persistence. + operations map[uint32]Operation + // nextOpID holds the next operation ID. + nextOpID *uint32 + // permission holds the permissions of the terminal. + permission Permission + + // opts holds the terminal options. It must not be modified after the terminal + // has started. + opts *TerminalOpts + + // lastUnknownOpID holds the operation ID of the last data message received + // for an unknown operation ID. + lastUnknownOpID uint32 + // lastUnknownOpMsgs holds the amount of continuous data messages received + // for the operation ID in lastUnknownOpID. + lastUnknownOpMsgs uint32 + + // Abandoning indicates if the Terminal is being abandoned. The main handlers + // will keep running until the context has been canceled by the abandon + // procedure. + // No new operations should be started. + // Whoever initiates the abandoning must also start the abandon procedure. + Abandoning *abool.AtomicBool +} + +func createTerminalBase( + ctx context.Context, + id uint32, + parentID string, + remote bool, + initMsg *TerminalOpts, + upstream Upstream, +) (*TerminalBase, *Error) { + t := &TerminalBase{ + id: id, + parentID: parentID, + sendQueue: make(chan *Msg), + upstream: upstream, + waitForFlush: abool.New(), + flush: make(chan func()), + idleTicker: time.NewTicker(time.Minute), + idleCounter: new(uint32), + encryptionReady: make(chan struct{}), + operations: make(map[uint32]Operation), + nextOpID: new(uint32), + opts: initMsg, + Abandoning: abool.New(), + } + // Stop ticking to disable timeout. + t.idleTicker.Stop() + // Shift next operation ID if remote. + if remote { + atomic.AddUint32(t.nextOpID, 4) + } + // Create context. + t.ctx, t.cancelCtx = context.WithCancel(ctx) + + // Create flow control. + switch initMsg.FlowControl { + case FlowControlDFQ: + t.flowControl = NewDuplexFlowQueue(t.Ctx(), initMsg.FlowControlSize, t.submitToUpstream) + t.deliverProxy = t.flowControl.Deliver + t.recvProxy = t.flowControl.Receive + case FlowControlNone: + deliver := make(chan *Msg, initMsg.FlowControlSize) + t.deliverProxy = MakeDirectDeliveryDeliverFunc(ctx, deliver) + t.recvProxy = MakeDirectDeliveryRecvFunc(deliver) + case FlowControlDefault: + fallthrough + default: + return nil, ErrInternalError.With("unknown flow control type %d", initMsg.FlowControl) + } + + return t, nil +} + +// ID returns the Terminal's ID. +func (t *TerminalBase) ID() uint32 { + return t.id +} + +// Ctx returns the Terminal's context. +func (t *TerminalBase) Ctx() context.Context { + return t.ctx +} + +// SetTerminalExtension sets the Terminal's extension. This function is not +// guarded and may only be used during initialization. +func (t *TerminalBase) SetTerminalExtension(ext Terminal) { + t.ext = ext +} + +// SetTimeout sets the Terminal's idle timeout duration. +// It is broken down into slots internally. +func (t *TerminalBase) SetTimeout(d time.Duration) { + t.idleTicker.Reset(d / timeoutTicks) +} + +// Deliver on TerminalBase only exists to conform to the interface. It must be +// overridden by an actual implementation. +func (t *TerminalBase) Deliver(msg *Msg) *Error { + // Deliver via configured proxy. + err := t.deliverProxy(msg) + if err != nil { + msg.Finish() + } + + return err +} + +// StartWorkers starts the necessary workers to operate the Terminal. +func (t *TerminalBase) StartWorkers(m *modules.Module, terminalName string) { + // Start terminal workers. + m.StartWorker(terminalName+" handler", t.Handler) + m.StartWorker(terminalName+" sender", t.Sender) + + // Start any flow control workers. + if t.flowControl != nil { + t.flowControl.StartWorkers(m, terminalName) + } +} + +const ( + sendThresholdLength = 100 // bytes + sendMaxLength = 4000 // bytes + sendThresholdMaxWait = 20 * time.Millisecond +) + +// Handler receives and handles messages and must be started as a worker in the +// module where the Terminal is used. +func (t *TerminalBase) Handler(_ context.Context) error { + defer t.Abandon(ErrInternalError.With("handler died")) + + var msg *Msg + defer msg.Finish() + + for { + select { + case <-t.ctx.Done(): + // Call Abandon just in case. + // Normally, only the StopProcedure function should cancel the context. + t.Abandon(nil) + return nil // Controlled worker exit. + + case <-t.idleTicker.C: + // If nothing happens for a while, end the session. + if atomic.AddUint32(t.idleCounter, 1) > timeoutTicks { + // Abandon the terminal and reset the counter. + t.Abandon(ErrNoActivity) + atomic.StoreUint32(t.idleCounter, 0) + } + + case msg = <-t.recvProxy(): + err := t.handleReceive(msg) + if err != nil { + t.Abandon(err.Wrap("failed to handle")) + return nil + } + + // Register activity. + atomic.StoreUint32(t.idleCounter, 0) + } + } +} + +// submit is used to send message from the terminal to upstream, including +// going through flow control, if configured. +// This function should be used to send message from the terminal to upstream. +func (t *TerminalBase) submit(msg *Msg, timeout time.Duration) { + // Submit directly if no flow control is configured. + if t.flowControl == nil { + t.submitToUpstream(msg, timeout) + return + } + + // Hand over to flow control. + err := t.flowControl.Send(msg, timeout) + if err != nil { + msg.Finish() + t.Abandon(err.Wrap("failed to submit to flow control")) + } +} + +// submitToUpstream is used to directly submit messages to upstream. +// This function should only be used by the flow control or submit function. +func (t *TerminalBase) submitToUpstream(msg *Msg, timeout time.Duration) { + // Add terminal ID as flow ID. + msg.FlowID = t.ID() + + // Debug unit leaks. + msg.debugWithCaller(2) + + // Submit to upstream. + err := t.upstream.Send(msg, timeout) + if err != nil { + msg.Finish() + t.Abandon(err.Wrap("failed to submit to upstream")) + } +} + +// Sender handles sending messages and must be started as a worker in the +// module where the Terminal is used. +func (t *TerminalBase) Sender(_ context.Context) error { + // Don't send messages, if the encryption is net yet set up. + // The server encryption session is only initialized with the first + // operative message, not on Terminal creation. + if t.opts.Encrypt { + select { + case <-t.ctx.Done(): + // Call Abandon just in case. + // Normally, the only the StopProcedure function should cancel the context. + t.Abandon(nil) + return nil // Controlled worker exit. + case <-t.encryptionReady: + } + } + + // Be sure to call Stop even in case of sudden death. + defer t.Abandon(ErrInternalError.With("sender died")) + + var msgBufferMsg *Msg + var msgBufferLen int + var msgBufferLimitReached bool + var sendMsgs bool + var sendMaxWait *time.Timer + var flushFinished func() + + // Finish any current unit when returning. + defer msgBufferMsg.Finish() + + // Only receive message when not sending the current msg buffer. + sendQueueOpMsgs := func() <-chan *Msg { + // Don't handle more messages, if the buffer is full. + if msgBufferLimitReached { + return nil + } + return t.sendQueue + } + + // Only wait for sending slot when the current msg buffer is ready to be sent. + readyToSend := func() <-chan struct{} { + switch { + case !sendMsgs: + // Wait until there is something to send. + return nil + case t.flowControl != nil: + // Let flow control decide when we are ready. + return t.flowControl.ReadyToSend() + default: + // Always ready. + return ready + } + } + + // Calculate current max wait time to send the msg buffer. + getSendMaxWait := func() <-chan time.Time { + if sendMaxWait != nil { + return sendMaxWait.C + } + return nil + } + +handling: + for { + select { + case <-t.ctx.Done(): + // Call Stop just in case. + // Normally, the only the StopProcedure function should cancel the context. + t.Abandon(nil) + return nil // Controlled worker exit. + + case <-t.idleTicker.C: + // If nothing happens for a while, end the session. + if atomic.AddUint32(t.idleCounter, 1) > timeoutTicks { + // Abandon the terminal and reset the counter. + t.Abandon(ErrNoActivity) + atomic.StoreUint32(t.idleCounter, 0) + } + + case msg := <-sendQueueOpMsgs(): + if msg == nil { + continue handling + } + + // Add unit to buffer unit, or use it as new buffer. + if msgBufferMsg != nil { + // Pack, append and finish additional message. + msgBufferMsg.Consume(msg) + } else { + // Pack operation message. + msg.Pack() + // Convert to message of terminal. + msgBufferMsg = msg + msgBufferMsg.FlowID = t.ID() + msgBufferMsg.Type = MsgTypeData + } + msgBufferLen += msg.Data.Length() + + // Check if there is enough data to hit the sending threshold. + if msgBufferLen >= sendThresholdLength { + sendMsgs = true + } else if sendMaxWait == nil && t.waitForFlush.IsNotSet() { + sendMaxWait = time.NewTimer(sendThresholdMaxWait) + } + + // Check if we have reached the maximum buffer size. + if msgBufferLen >= sendMaxLength { + msgBufferLimitReached = true + } + + // Register activity. + atomic.StoreUint32(t.idleCounter, 0) + + case <-getSendMaxWait(): + // The timer for waiting for more data has ended. + // Send all available data if not forced to wait for a flush. + if t.waitForFlush.IsNotSet() { + sendMsgs = true + } + + case newFlushFinishedFn := <-t.flush: + // We are flushing - stop waiting. + t.waitForFlush.UnSet() + + // Signal immediately if msg buffer is empty. + if msgBufferLen == 0 { + newFlushFinishedFn() + } else { + // If there already is a flush finished function, stack them. + if flushFinished != nil { + stackedFlushFinishFn := flushFinished + flushFinished = func() { + stackedFlushFinishFn() + newFlushFinishedFn() + } + } else { + flushFinished = newFlushFinishedFn + } + } + + // Force sending data now. + sendMsgs = true + + case <-readyToSend(): + // Reset sending flags. + sendMsgs = false + msgBufferLimitReached = false + + // Send if there is anything to send. + var err *Error + if msgBufferLen > 0 { + // Update message type to include priority. + if msgBufferMsg.Type == MsgTypeData && + msgBufferMsg.Unit.IsHighPriority() && + t.opts.UsePriorityDataMsgs { + msgBufferMsg.Type = MsgTypePriorityData + } + + // Wait for clearance on initial msg only. + msgBufferMsg.Unit.WaitForSlot() + + err = t.sendOpMsgs(msgBufferMsg) + } + + // Reset buffer. + msgBufferMsg = nil + msgBufferLen = 0 + + // Reset send wait timer. + if sendMaxWait != nil { + sendMaxWait.Stop() + sendMaxWait = nil + } + + // Check if we are flushing and need to notify. + if flushFinished != nil { + flushFinished() + flushFinished = nil + } + + // Handle error after state updates. + if err != nil { + t.Abandon(err.With("failed to send")) + continue handling + } + } + } +} + +// WaitForFlush makes the terminal pause all sending until the next call to +// Flush(). +func (t *TerminalBase) WaitForFlush() { + t.waitForFlush.Set() +} + +// Flush sends all data waiting to be sent. +func (t *TerminalBase) Flush(timeout time.Duration) { + // Create channel and function for notifying. + wait := make(chan struct{}) + finished := func() { + close(wait) + } + // Request flush and return when stopping. + select { + case t.flush <- finished: + case <-t.Ctx().Done(): + return + case <-TimedOut(timeout): + return + } + // Wait for flush to finish and return when stopping. + select { + case <-wait: + case <-t.Ctx().Done(): + return + case <-TimedOut(timeout): + return + } + + // Flush flow control, if configured. + if t.flowControl != nil { + t.flowControl.Flush(timeout) + } +} + +func (t *TerminalBase) encrypt(c *container.Container) (*container.Container, *Error) { + if !t.opts.Encrypt { + return c, nil + } + + t.jessionLock.Lock() + defer t.jessionLock.Unlock() + + letter, err := t.jession.Close(c.CompileData()) + if err != nil { + return nil, ErrIntegrity.With("failed to encrypt: %w", err) + } + + encryptedData, err := letter.ToWire() + if err != nil { + return nil, ErrInternalError.With("failed to pack letter: %w", err) + } + + return encryptedData, nil +} + +func (t *TerminalBase) decrypt(c *container.Container) (*container.Container, *Error) { + if !t.opts.Encrypt { + return c, nil + } + + t.jessionLock.Lock() + defer t.jessionLock.Unlock() + + letter, err := jess.LetterFromWire(c) + if err != nil { + return nil, ErrMalformedData.With("failed to parse letter: %w", err) + } + + // Setup encryption if not yet done. + if t.jession == nil { + if t.identity == nil { + return nil, ErrInternalError.With("missing identity for setting up incoming encryption") + } + + // Create jess session. + t.jession, err = letter.WireCorrespondence(t.identity) + if err != nil { + return nil, ErrIntegrity.With("failed to initialize incoming encryption: %w", err) + } + + // Don't need that anymore. + t.identity = nil + + // Encryption is ready for sending. + close(t.encryptionReady) + } + + decryptedData, err := t.jession.Open(letter) + if err != nil { + return nil, ErrIntegrity.With("failed to decrypt: %w", err) + } + + return container.New(decryptedData), nil +} + +func (t *TerminalBase) handleReceive(msg *Msg) *Error { + msg.Unit.WaitForSlot() + defer msg.Finish() + + // Debugging: + // log.Errorf("spn/terminal %s handling tmsg: %s", t.FmtID(), spew.Sdump(c.CompileData())) + + // Check if message is empty. This will be the case if a message was only + // for updated the available space of the flow queue. + if !msg.Data.HoldsData() { + return nil + } + + // Decrypt if enabled. + var tErr *Error + msg.Data, tErr = t.decrypt(msg.Data) + if tErr != nil { + return tErr + } + + // Handle operation messages. + for msg.Data.HoldsData() { + // Get next message length. + msgLength, err := msg.Data.GetNextN32() + if err != nil { + return ErrMalformedData.With("failed to get operation msg length: %w", err) + } + if msgLength == 0 { + // Remainder is padding. + // Padding can only be at the end of the segment. + t.handlePaddingMsg(msg.Data) + return nil + } + + // Get op msg data. + msgData, err := msg.Data.GetAsContainer(int(msgLength)) + if err != nil { + return ErrMalformedData.With("failed to get operation msg data (%d/%d bytes): %w", msg.Data.Length(), msgLength, err) + } + + // Handle op msg. + if handleErr := t.handleOpMsg(msgData); handleErr != nil { + return handleErr + } + } + + return nil +} + +func (t *TerminalBase) handleOpMsg(data *container.Container) *Error { + // Debugging: + // log.Errorf("spn/terminal %s handling opmsg: %s", t.FmtID(), spew.Sdump(data.CompileData())) + + // Parse message operation id, type. + opID, msgType, err := ParseIDType(data) + if err != nil { + return ErrMalformedData.With("failed to parse operation msg id/type: %w", err) + } + + switch msgType { + case MsgTypeInit: + t.handleOperationStart(opID, data) + + case MsgTypeData, MsgTypePriorityData: + op, ok := t.GetActiveOp(opID) + if ok && !op.Stopped() { + // Create message from data. + msg := NewEmptyMsg() + msg.FlowID = opID + msg.Type = msgType + msg.Data = data + if msg.Type == MsgTypePriorityData { + msg.Unit.MakeHighPriority() + } + + // Deliver message to operation. + tErr := op.Deliver(msg) + if tErr != nil { + // Also stop on "success" errors! + msg.Finish() + t.StopOperation(op, tErr) + } + return nil + } + + // If an active op is not found, this is likely just left-overs from a + // stopped or failed operation. + // log.Tracef("spn/terminal: %s received data msg for unknown op %d", fmtTerminalID(t.parentID, t.id), opID) + + // Send a stop error if this happens too often. + if opID == t.lastUnknownOpID { + // OpID is the same as last time. + t.lastUnknownOpMsgs++ + + // Log an warning (via StopOperation) and send a stop message every thousand. + if t.lastUnknownOpMsgs%1000 == 0 { + t.StopOperation(newUnknownOp(opID, ""), ErrUnknownOperationID.With("received %d unsolicited data msgs", t.lastUnknownOpMsgs)) + } + + // TODO: Abandon terminal at over 10000? + } else { + // OpID changed, set new ID and reset counter. + t.lastUnknownOpID = opID + t.lastUnknownOpMsgs = 1 + } + + case MsgTypeStop: + // Parse received error. + opErr, parseErr := ParseExternalError(data.CompileData()) + if parseErr != nil { + log.Warningf("spn/terminal: %s failed to parse stop error: %s", fmtTerminalID(t.parentID, t.id), parseErr) + opErr = ErrUnknownError.AsExternal() + } + + // End operation. + op, ok := t.GetActiveOp(opID) + if ok { + t.StopOperation(op, opErr) + } else { + log.Tracef("spn/terminal: %s received stop msg for unknown op %d", fmtTerminalID(t.parentID, t.id), opID) + } + + default: + log.Warningf("spn/terminal: %s received unexpected message type: %d", t.FmtID(), msgType) + return ErrUnexpectedMsgType + } + + return nil +} + +func (t *TerminalBase) handlePaddingMsg(c *container.Container) { + padding := c.GetAll() + if len(padding) > 0 { + rngFeeder.SupplyEntropyIfNeeded(padding, len(padding)) + } +} + +func (t *TerminalBase) sendOpMsgs(msg *Msg) *Error { + msg.Unit.WaitForSlot() + + // Add Padding if needed. + if t.opts.Padding > 0 { + paddingNeeded := (int(t.opts.Padding) - msg.Data.Length()) % int(t.opts.Padding) + if paddingNeeded > 0 { + // Add padding message header. + msg.Data.Append([]byte{0}) + paddingNeeded-- + + // Add needed padding data. + if paddingNeeded > 0 { + padding, err := rng.Bytes(paddingNeeded) + if err != nil { + log.Debugf("spn/terminal: %s failed to get random data, using zeros instead", t.FmtID()) + padding = make([]byte, paddingNeeded) + } + msg.Data.Append(padding) + } + } + } + + // Encrypt operative data. + var tErr *Error + msg.Data, tErr = t.encrypt(msg.Data) + if tErr != nil { + return tErr + } + + // Send data. + t.submit(msg, 0) + return nil +} + +// Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon(). +// Should not be overridden by implementations. +func (t *TerminalBase) Abandon(err *Error) { + if t.Abandoning.SetToIf(false, true) { + module.StartWorker("terminal abandon procedure", func(_ context.Context) error { + t.handleAbandonProcedure(err) + return nil + }) + } +} + +// HandleAbandon gives the terminal the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Abandon() instead. +// Meant to be overridden by implementations. +func (t *TerminalBase) HandleAbandon(err *Error) (errorToSend *Error) { + return err +} + +// HandleDestruction gives the terminal the ability to clean up. +// The terminal has already fully shut down at this point. +// Should never be called directly. Call Abandon() instead. +// Meant to be overridden by implementations. +func (t *TerminalBase) HandleDestruction(err *Error) {} + +func (t *TerminalBase) handleAbandonProcedure(err *Error) { + // End all operations. + for _, op := range t.allOps() { + t.StopOperation(op, nil) + } + + // Prepare timeouts for waiting for ops. + timeout := clientTerminalAbandonTimeout + if conf.PublicHub() { + timeout = serverTerminalAbandonTimeout + } + checkTicker := time.NewTicker(50 * time.Millisecond) + defer checkTicker.Stop() + abortWaiting := time.After(timeout) + + // Wait for all operations to end. +waitForOps: + for { + select { + case <-checkTicker.C: + if t.GetActiveOpCount() <= 0 { + break waitForOps + } + case <-abortWaiting: + log.Warningf( + "spn/terminal: terminal %s is continuing shutdown with %d active operations", + t.FmtID(), + t.GetActiveOpCount(), + ) + break waitForOps + } + } + + // Call operation stop handle function for proper shutdown cleaning up. + if t.ext != nil { + err = t.ext.HandleAbandon(err) + } + + // Send error to the connected Operation, if the error is internal. + if !err.IsExternal() { + if err == nil { + err = ErrStopping + } + + msg := NewMsg(err.Pack()) + msg.FlowID = t.ID() + msg.Type = MsgTypeStop + t.submit(msg, 1*time.Second) + } + + // If terminal was ended locally, send all data before abandoning. + // If terminal was ended remotely, don't bother sending remaining data. + if !err.IsExternal() { + // Flushing could mean sending a full buffer of 50000 packets. + t.Flush(5 * time.Minute) + } + + // Stop all other connected workers. + t.cancelCtx() + t.idleTicker.Stop() + + // Call operation destruction handle function for proper shutdown cleaning up. + if t.ext != nil { + t.ext.HandleDestruction(err) + } +} + +func (t *TerminalBase) allOps() []Operation { + t.lock.Lock() + defer t.lock.Unlock() + + ops := make([]Operation, 0, len(t.operations)) + for _, op := range t.operations { + ops = append(ops, op) + } + + return ops +} + +// MakeDirectDeliveryDeliverFunc creates a submit upstream function with the +// given delivery channel. +func MakeDirectDeliveryDeliverFunc( + ctx context.Context, + deliver chan *Msg, +) func(c *Msg) *Error { + return func(c *Msg) *Error { + select { + case deliver <- c: + return nil + case <-ctx.Done(): + return ErrStopping + } + } +} + +// MakeDirectDeliveryRecvFunc makes a delivery receive function with the given +// delivery channel. +func MakeDirectDeliveryRecvFunc( + deliver chan *Msg, +) func() <-chan *Msg { + return func() <-chan *Msg { + return deliver + } +} diff --git a/spn/terminal/terminal_test.go b/spn/terminal/terminal_test.go new file mode 100644 index 00000000..b458f696 --- /dev/null +++ b/spn/terminal/terminal_test.go @@ -0,0 +1,311 @@ +package terminal + +import ( + "fmt" + "os" + "runtime/pprof" + "sync/atomic" + "testing" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/hub" +) + +func TestTerminals(t *testing.T) { + t.Parallel() + + identity, erro := cabin.CreateIdentity(module.Ctx, "test") + if erro != nil { + t.Fatalf("failed to create identity: %s", erro) + } + + // Test without and with encryption. + for _, encrypt := range []bool{false, true} { + // Test with different flow controls. + for _, fc := range []struct { + flowControl FlowControlType + flowControlSize uint32 + }{ + { + flowControl: FlowControlNone, + flowControlSize: 5, + }, + { + flowControl: FlowControlDFQ, + flowControlSize: defaultTestQueueSize, + }, + } { + // Run tests with combined options. + testTerminals(t, identity, &TerminalOpts{ + Encrypt: encrypt, + Padding: defaultTestPadding, + FlowControl: fc.flowControl, + FlowControlSize: fc.flowControlSize, + }) + } + } +} + +func testTerminals(t *testing.T, identity *cabin.Identity, terminalOpts *TerminalOpts) { + t.Helper() + + // Prepare encryption. + var dstHub *hub.Hub + if terminalOpts.Encrypt { + dstHub = identity.Hub + } else { + identity = nil + } + + // Create test terminals. + var term1 *TestTerminal + var term2 *TestTerminal + var initData *container.Container + var err *Error + term1, initData, err = NewLocalTestTerminal( + module.Ctx, 127, "c1", dstHub, terminalOpts, createForwardingUpstream( + t, "c1", "c2", func(msg *Msg) *Error { + return term2.Deliver(msg) + }, + ), + ) + if err != nil { + t.Fatalf("failed to create local terminal: %s", err) + } + term2, _, err = NewRemoteTestTerminal( + module.Ctx, 127, "c2", identity, initData, createForwardingUpstream( + t, "c2", "c1", func(msg *Msg) *Error { + return term1.Deliver(msg) + }, + ), + ) + if err != nil { + t.Fatalf("failed to create remote terminal: %s", err) + } + + // Start testing with counters. + countToQueueSize := uint64(terminalOpts.FlowControlSize) + optionsSuffix := fmt.Sprintf( + "encrypt=%v,flowType=%d", + terminalOpts.Encrypt, + terminalOpts.FlowControl, + ) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlyup-flushing-waiting:" + optionsSuffix, + flush: true, + serverCountTo: countToQueueSize * 2, + waitBetweenMsgs: sendThresholdMaxWait * 2, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlyup-waiting:" + optionsSuffix, + serverCountTo: 10, + waitBetweenMsgs: sendThresholdMaxWait * 2, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlyup-flushing:" + optionsSuffix, + flush: true, + serverCountTo: countToQueueSize * 2, + waitBetweenMsgs: time.Millisecond, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlyup:" + optionsSuffix, + serverCountTo: countToQueueSize * 2, + waitBetweenMsgs: time.Millisecond, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlydown-flushing-waiting:" + optionsSuffix, + flush: true, + clientCountTo: countToQueueSize * 2, + waitBetweenMsgs: sendThresholdMaxWait * 2, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlydown-waiting:" + optionsSuffix, + clientCountTo: 10, + waitBetweenMsgs: sendThresholdMaxWait * 2, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlydown-flushing:" + optionsSuffix, + flush: true, + clientCountTo: countToQueueSize * 2, + waitBetweenMsgs: time.Millisecond, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "onlydown:" + optionsSuffix, + clientCountTo: countToQueueSize * 2, + waitBetweenMsgs: time.Millisecond, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "twoway-flushing-waiting:" + optionsSuffix, + flush: true, + clientCountTo: countToQueueSize * 2, + serverCountTo: countToQueueSize * 2, + waitBetweenMsgs: sendThresholdMaxWait * 2, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "twoway-waiting:" + optionsSuffix, + flush: true, + clientCountTo: 10, + serverCountTo: 10, + waitBetweenMsgs: sendThresholdMaxWait * 2, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "twoway-flushing:" + optionsSuffix, + flush: true, + clientCountTo: countToQueueSize * 2, + serverCountTo: countToQueueSize * 2, + waitBetweenMsgs: time.Millisecond, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "twoway:" + optionsSuffix, + clientCountTo: countToQueueSize * 2, + serverCountTo: countToQueueSize * 2, + waitBetweenMsgs: time.Millisecond, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "stresstest-down:" + optionsSuffix, + clientCountTo: countToQueueSize * 1000, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "stresstest-up:" + optionsSuffix, + serverCountTo: countToQueueSize * 1000, + }) + + testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{ + testName: "stresstest-duplex:" + optionsSuffix, + clientCountTo: countToQueueSize * 1000, + serverCountTo: countToQueueSize * 1000, + }) + + // Clean up. + term1.Abandon(nil) + term2.Abandon(nil) + + // Give some time for the last log messages and clean up. + time.Sleep(100 * time.Millisecond) +} + +func createForwardingUpstream(t *testing.T, srcName, dstName string, deliverFunc func(*Msg) *Error) Upstream { + t.Helper() + + return UpstreamSendFunc(func(msg *Msg, _ time.Duration) *Error { + // Fast track nil containers. + if msg == nil { + dErr := deliverFunc(msg) + if dErr != nil { + t.Errorf("%s>%s: failed to deliver nil msg to terminal: %s", srcName, dstName, dErr) + return dErr.With("failed to deliver nil msg to terminal") + } + return nil + } + + // Log messages. + if logTestCraneMsgs { + t.Logf("%s>%s: %v\n", srcName, dstName, msg.Data.CompileData()) + } + + // Deliver to other terminal. + dErr := deliverFunc(msg) + if dErr != nil { + t.Errorf("%s>%s: failed to deliver to terminal: %s", srcName, dstName, dErr) + return dErr.With("failed to deliver to terminal") + } + + return nil + }) +} + +type testWithCounterOpts struct { + testName string + flush bool + clientCountTo uint64 + serverCountTo uint64 + waitBetweenMsgs time.Duration +} + +func testTerminalWithCounters(t *testing.T, term1, term2 *TestTerminal, opts *testWithCounterOpts) { + t.Helper() + + // Wait async for test to complete, print stack after timeout. + finished := make(chan struct{}) + maxTestDuration := 60 * time.Second + go func() { + select { + case <-finished: + case <-time.After(maxTestDuration): + fmt.Printf("terminal test %s is taking more than %s, printing stack:\n", opts.testName, maxTestDuration) + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) + os.Exit(1) + } + }() + + t.Logf("starting terminal counter test %s", opts.testName) + defer t.Logf("stopping terminal counter test %s", opts.testName) + + // Start counters. + counter, tErr := NewCounterOp(term1, CounterOpts{ + ClientCountTo: opts.clientCountTo, + ServerCountTo: opts.serverCountTo, + Flush: opts.flush, + Wait: opts.waitBetweenMsgs, + }) + if tErr != nil { + t.Fatalf("terminal test %s failed to start counter: %s", opts.testName, tErr) + } + + // Wait until counters are done. + counter.Wait() + close(finished) + + // Check for error. + if counter.Error != nil { + t.Fatalf("terminal test %s failed to count: %s", opts.testName, counter.Error) + } + + // Log stats. + printCTStats(t, opts.testName, "term1", term1) + printCTStats(t, opts.testName, "term2", term2) + + // Check if stats match, if DFQ is used on both sides. + dfq1, ok1 := term1.flowControl.(*DuplexFlowQueue) + dfq2, ok2 := term2.flowControl.(*DuplexFlowQueue) + if ok1 && ok2 && + (atomic.LoadInt32(dfq1.sendSpace) != atomic.LoadInt32(dfq2.reportedSpace) || + atomic.LoadInt32(dfq2.sendSpace) != atomic.LoadInt32(dfq1.reportedSpace)) { + t.Fatalf("terminal test %s has non-matching space counters", opts.testName) + } +} + +func printCTStats(t *testing.T, testName, name string, term *TestTerminal) { + t.Helper() + + dfq, ok := term.flowControl.(*DuplexFlowQueue) + if !ok { + return + } + + t.Logf( + "%s: %s: sq=%d rq=%d sends=%d reps=%d", + testName, + name, + len(dfq.sendQueue), + len(dfq.recvQueue), + atomic.LoadInt32(dfq.sendSpace), + atomic.LoadInt32(dfq.reportedSpace), + ) +} diff --git a/spn/terminal/testing.go b/spn/terminal/testing.go new file mode 100644 index 00000000..22b12608 --- /dev/null +++ b/spn/terminal/testing.go @@ -0,0 +1,243 @@ +package terminal + +import ( + "context" + "time" + + "github.com/safing/portbase/container" + "github.com/safing/portbase/log" + "github.com/safing/portmaster/spn/cabin" + "github.com/safing/portmaster/spn/hub" +) + +const ( + defaultTestQueueSize = 16 + defaultTestPadding = 8 + logTestCraneMsgs = false +) + +// TestTerminal is a terminal for running tests. +type TestTerminal struct { + *TerminalBase +} + +// NewLocalTestTerminal returns a new local test terminal. +func NewLocalTestTerminal( + ctx context.Context, + id uint32, + parentID string, + remoteHub *hub.Hub, + initMsg *TerminalOpts, + upstream Upstream, +) (*TestTerminal, *container.Container, *Error) { + // Create Terminal Base. + t, initData, err := NewLocalBaseTerminal(ctx, id, parentID, remoteHub, initMsg, upstream) + if err != nil { + return nil, nil, err + } + t.StartWorkers(module, "test terminal") + + return &TestTerminal{t}, initData, nil +} + +// NewRemoteTestTerminal returns a new remote test terminal. +func NewRemoteTestTerminal( + ctx context.Context, + id uint32, + parentID string, + identity *cabin.Identity, + initData *container.Container, + upstream Upstream, +) (*TestTerminal, *TerminalOpts, *Error) { + // Create Terminal Base. + t, initMsg, err := NewRemoteBaseTerminal(ctx, id, parentID, identity, initData, upstream) + if err != nil { + return nil, nil, err + } + t.StartWorkers(module, "test terminal") + + return &TestTerminal{t}, initMsg, nil +} + +type delayedMsg struct { + msg *Msg + timeout time.Duration + delayUntil time.Time +} + +func createDelayingTestForwardingFunc( + srcName, + dstName string, + delay time.Duration, + delayQueueSize int, + deliverFunc func(msg *Msg, timeout time.Duration) *Error, +) func(msg *Msg, timeout time.Duration) *Error { + // Return simple forward func if no delay is given. + if delay == 0 { + return func(msg *Msg, timeout time.Duration) *Error { + // Deliver to other terminal. + dErr := deliverFunc(msg, timeout) + if dErr != nil { + log.Errorf("spn/testing: %s>%s: failed to deliver to terminal: %s", srcName, dstName, dErr) + return dErr + } + return nil + } + } + + // If there is delay, create a delaying channel and handler. + delayedMsgs := make(chan *delayedMsg, delayQueueSize) + go func() { + for { + // Read from chan + msg := <-delayedMsgs + if msg == nil { + return + } + + // Check if we need to wait. + waitFor := time.Until(msg.delayUntil) + if waitFor > 0 { + time.Sleep(waitFor) + } + + // Deliver to other terminal. + dErr := deliverFunc(msg.msg, msg.timeout) + if dErr != nil { + log.Errorf("spn/testing: %s>%s: failed to deliver to terminal: %s", srcName, dstName, dErr) + } + } + }() + + return func(msg *Msg, timeout time.Duration) *Error { + // Add msg to delaying msg channel. + delayedMsgs <- &delayedMsg{ + msg: msg, + timeout: timeout, + delayUntil: time.Now().Add(delay), + } + return nil + } +} + +// HandleAbandon gives the terminal the ability to cleanly shut down. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Abandon() instead. +func (t *TestTerminal) HandleAbandon(err *Error) (errorToSend *Error) { + switch err { + case nil: + // nil means that the Terminal is being shutdown by the owner. + log.Tracef("spn/terminal: %s is closing", fmtTerminalID(t.parentID, t.id)) + default: + // All other errors are faults. + log.Warningf("spn/terminal: %s: %s", fmtTerminalID(t.parentID, t.id), err) + } + + return +} + +// NewSimpleTestTerminalPair provides a simple conntected terminal pair for tests. +func NewSimpleTestTerminalPair(delay time.Duration, delayQueueSize int, opts *TerminalOpts) (a, b *TestTerminal, err error) { + if opts == nil { + opts = &TerminalOpts{ + Padding: defaultTestPadding, + FlowControl: FlowControlDFQ, + FlowControlSize: defaultTestQueueSize, + } + } + + var initData *container.Container + var tErr *Error + a, initData, tErr = NewLocalTestTerminal( + module.Ctx, 127, "a", nil, opts, UpstreamSendFunc(createDelayingTestForwardingFunc( + "a", "b", delay, delayQueueSize, func(msg *Msg, timeout time.Duration) *Error { + return b.Deliver(msg) + }, + )), + ) + if tErr != nil { + return nil, nil, tErr.Wrap("failed to create local test terminal") + } + b, _, tErr = NewRemoteTestTerminal( + module.Ctx, 127, "b", nil, initData, UpstreamSendFunc(createDelayingTestForwardingFunc( + "b", "a", delay, delayQueueSize, func(msg *Msg, timeout time.Duration) *Error { + return a.Deliver(msg) + }, + )), + ) + if tErr != nil { + return nil, nil, tErr.Wrap("failed to create remote test terminal") + } + + return a, b, nil +} + +// BareTerminal is a bare terminal that just returns errors for testing. +type BareTerminal struct{} + +var ( + _ Terminal = &BareTerminal{} + + errNotImplementedByBareTerminal = ErrInternalError.With("not implemented by bare terminal") +) + +// ID returns the terminal ID. +func (t *BareTerminal) ID() uint32 { + return 0 +} + +// Ctx returns the terminal context. +func (t *BareTerminal) Ctx() context.Context { + return context.Background() +} + +// Deliver delivers a message to the terminal. +// Should not be overridden by implementations. +func (t *BareTerminal) Deliver(msg *Msg) *Error { + return errNotImplementedByBareTerminal +} + +// Send is used by others to send a message through the terminal. +// Should not be overridden by implementations. +func (t *BareTerminal) Send(msg *Msg, timeout time.Duration) *Error { + return errNotImplementedByBareTerminal +} + +// Flush sends all messages waiting in the terminal. +// Should not be overridden by implementations. +func (t *BareTerminal) Flush(timeout time.Duration) {} + +// StartOperation starts the given operation by assigning it an ID and sending the given operation initialization data. +// Should not be overridden by implementations. +func (t *BareTerminal) StartOperation(op Operation, initData *container.Container, timeout time.Duration) *Error { + return errNotImplementedByBareTerminal +} + +// StopOperation stops the given operation. +// Should not be overridden by implementations. +func (t *BareTerminal) StopOperation(op Operation, err *Error) {} + +// Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon(). +// Should not be overridden by implementations. +func (t *BareTerminal) Abandon(err *Error) {} + +// HandleAbandon gives the terminal the ability to cleanly shut down. +// The terminal is still fully functional at this point. +// The returned error is the error to send to the other side. +// Should never be called directly. Call Abandon() instead. +// Meant to be overridden by implementations. +func (t *BareTerminal) HandleAbandon(err *Error) (errorToSend *Error) { + return err +} + +// HandleDestruction gives the terminal the ability to clean up. +// The terminal has already fully shut down at this point. +// Should never be called directly. Call Abandon() instead. +// Meant to be overridden by implementations. +func (t *BareTerminal) HandleDestruction(err *Error) {} + +// FmtID formats the terminal ID (including parent IDs). +// May be overridden by implementations. +func (t *BareTerminal) FmtID() string { + return "bare" +} diff --git a/spn/terminal/upstream.go b/spn/terminal/upstream.go new file mode 100644 index 00000000..9dd27d43 --- /dev/null +++ b/spn/terminal/upstream.go @@ -0,0 +1,16 @@ +package terminal + +import "time" + +// Upstream defines the interface for upstream (parent) components. +type Upstream interface { + Send(msg *Msg, timeout time.Duration) *Error +} + +// UpstreamSendFunc is a helper to be able to satisfy the Upstream interface. +type UpstreamSendFunc func(msg *Msg, timeout time.Duration) *Error + +// Send is used to send a message through this upstream. +func (fn UpstreamSendFunc) Send(msg *Msg, timeout time.Duration) *Error { + return fn(msg, timeout) +} diff --git a/test b/spn/test similarity index 79% rename from test rename to spn/test index 71a65921..2a443bb4 100755 --- a/test +++ b/spn/test @@ -131,29 +131,6 @@ if [[ $testonly -eq 0 ]]; then fi fi -# build portmaster-core for for all supported platforms -echo "building portmaster-core for all platforms" -cd ./cmds/portmaster-core -run env GOOS=linux GOARCH=amd64 ./build -run env GOOS=windows GOARCH=amd64 ./build -run env GOOS=darwin GOARCH=amd64 ./build -run env GOOS=linux GOARCH=arm64 ./build -run env GOOS=windows GOARCH=arm64 ./build -run env GOOS=darwin GOARCH=arm64 ./build -cd "$baseDir" - -# build portmaster-start for for all supported platforms -echo "" -echo "building portmaster-start for all platforms" -cd ./cmds/portmaster-start -run env GOOS=linux GOARCH=amd64 ./build -# run env GOOS=windows GOARCH=amd64 ./build # TODO: Fix for GitHub CI -run env GOOS=darwin GOARCH=amd64 ./build -run env GOOS=linux GOARCH=arm64 ./build -# run env GOOS=windows GOARCH=arm64 ./build # TODO: Fix for GitHub CI -run env GOOS=darwin GOARCH=arm64 ./build -cd "$baseDir" - # target selection if [[ "$1" == "" ]]; then # get all packages @@ -161,18 +138,16 @@ if [[ "$1" == "" ]]; then else # single package testing packages=$(go list -e)/$1 - echo "" echo "note: only running tests for package $packages" fi # platform info -echo "" platformInfo=$(go env GOOS GOARCH) echo "running tests for ${platformInfo//$'\n'/ }:" # run vet/test on packages for package in $packages; do - packagename=${package#github.com/safing/portmaster} #TODO: could be queried with `go list .` + packagename=${package#github.com/safing/spn} #TODO: could be queried with `go list .` packagename=${packagename#/} echo "" echo $package diff --git a/spn/tools/Dockerfile b/spn/tools/Dockerfile new file mode 100644 index 00000000..dbe39af1 --- /dev/null +++ b/spn/tools/Dockerfile @@ -0,0 +1,23 @@ +FROM alpine as builder + +# Ensure ca-certficates are up to date +# RUN update-ca-certificates + +# Download and verify portmaster-start binary. +RUN mkdir /init +RUN wget https://updates.safing.io/linux_amd64/start/portmaster-start_v0-9-6 -O /init/portmaster-start +COPY start-checksum.txt /init/start-checksum +RUN cd /init && sha256sum -c /init/start-checksum +RUN chmod 555 /init/portmaster-start + +# Use minimal image as base. +FROM alpine + +# Copy the static executable. +COPY --from=builder /init/portmaster-start /init/portmaster-start + +# Copy the init script +COPY container-init.sh /init.sh + +# Run the hub. +ENTRYPOINT ["/init.sh"] diff --git a/spn/tools/container-init.sh b/spn/tools/container-init.sh new file mode 100755 index 00000000..e5120872 --- /dev/null +++ b/spn/tools/container-init.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +DATA="/data" +START="/data/portmaster-start" +INIT_START="/init/portmaster-start" + +# Set safe shell options. +set -euf -o pipefail + +# Check if data dir is mounted. +if [ ! -d $DATA ]; then + echo "Nothing mounted at $DATA, aborting." + exit 1 +fi + +# Copy init start to correct location, if not available. +if [ ! -f $START ]; then + cp $INIT_START $START +fi + +# Download updates. +echo "running: $START update --data /data --intel-only" +$START update --data /data --intel-only + +# Remove PID file, which could have been left after a crash. +rm -f $DATA/hub-lock.pid + +# Always start the SPN Hub with the updated main start binary. +echo "running: $START hub --data /data -- $@" +$START hub --data /data -- $@ diff --git a/spn/tools/install.sh b/spn/tools/install.sh new file mode 100755 index 00000000..e7cf8fd7 --- /dev/null +++ b/spn/tools/install.sh @@ -0,0 +1,326 @@ +#!/bin/sh +# +# This script should be run via curl as root: +# sudo sh -c "$(curl -fsSL https://raw.githubusercontent.com/safing/portmaster/master/spn/tools/install-spn.sh)" +# or wget +# sudo sh -c "$(wget -qO- https://raw.githubusercontent.com/safing/portmaster/master/spn/tools/install-spn.sh)" +# +# As an alternative, you can first download the install script and run it afterwards: +# wget https://raw.githubusercontent.com/safing/portmaster/master/spn/tools/install-spn.sh +# sudo sh ./install.sh +# +# +set -e + +ARCH= +INSTALLDIR= +PMSTART= +ENABLENOW= +INSTALLSYSTEMD= +SYSTEMDINSTALLPATH= + +apply_defaults() { + ARCH=${ARCH:-amd64} + INSTALLDIR=${INSTALLDIR:-/opt/safing/spn} + PMSTART=${PMSTART:-https://updates.safing.io/latest/linux_${ARCH}/start/portmaster-start} + SYSTEMDINSTALLPATH=${SYSTEMDINSTALLPATH:-/etc/systemd/system/spn.service} + + if command_exists systemctl; then + INSTALLSYSTEMD=${INSTALLSYSTEMD:-yes} + ENABLENOW=${ENABLENOW:-yes} + else + INSTALLSYSTEMD=${INSTALLSYSTEMD:-no} + ENABLENOW=${ENABLENOW:-no} + fi + + # The hostname may be freshly set, ensure the ENV variable is correct. + export HOSTNAME=$(hostname) +} + +command_exists() { + command -v "$@" >/dev/null 2>&1 +} + +setup_tty() { + if [ -t 0 ]; then + interactive=yes + fi + + if [ -t 1 ]; then + RED=$(printf '\033[31m') + GREEN=$(printf '\033[32m') + YELLOW=$(printf '\033[33m') + BLUE=$(printf '\033[34m') + BOLD=$(printf '\033[1m') + RESET=$(printf '\033[m') + else + RED="" + GREEN="" + YELLOW="" + BLUE="" + BOLD="" + RESET="" + fi +} + +log() { + echo ${GREEN}${BOLD}"-> "${RESET}"$@" >&2 +} + +error() { + echo ${RED}"Error: $@"${RESET} >&2 +} + +warn() { + echo ${YELLOW}"warn: $@"${RESET} >&2 +} + +run_systemctl() { + systemctl $@ >/dev/null 2>&1 +} + +download_file() { + local src=$1 + local dest=$2 + + if command_exists curl; then + curl --silent --fail --show-error --location --output $dest $src + elif command_exists wget; then + wget --quiet -O $dest $src + else + error "No suitable download command found, either curl or wget must be installed" + exit 1 + fi +} + +ensure_install_dir() { + log "Creating ${INSTALLDIR}" + mkdir -p ${INSTALLDIR} +} + +download_pmstart() { + log "Downloading portmaster-start ..." + local dest="${INSTALLDIR}/portmaster-start" + if [ -f "${dest}" ]; then + warn "Overwriting existing portmaster-start at ${dest}" + fi + + download_file ${PMSTART} ${dest} + + log "Changing permissions" + chmod a+x ${dest} +} + +download_updates() { + log "Downloading updates ..." + ${INSTALLDIR}/portmaster-start --data=${INSTALLDIR} update +} + +setup_systemd() { + log "Installing systemd service unit ..." + if [ ! "${INSTALLSYSTEMD}" = "yes" ]; then + warn "Skipping setup of systemd service unit" + echo "To launch the hub, execute the following as root:" + echo "" + echo "${INSTALLDIR}/portmaster-start --data ${INSTALLDIR} hub" + echo "" + return + fi + + if [ -f "${SYSTEMDINSTALLPATH}" ]; then + warn "Overwriting existing unit path" + fi + + cat >${SYSTEMDINSTALLPATH} < " HOSTNAME + fi + if [ "${METRICS_COMMENT}" = "" ]; then + log "Please enter metrics comment:" + read -p "> " METRICS_COMMENT + fi +} + +write_config_file() { + cat >${1} < /etc/sysctl.d/9999-spn-network-optimizing.conf +# cat /etc/sysctl.d/9999-spn-network-optimizing.conf +# sysctl -p /etc/sysctl.d/9999-spn-network-optimizing.conf + +# Provide adequate buffer memory. +# net.ipv4.tcp_mem is in 4096-byte pages. +net.core.rmem_max = 1073741824 +net.core.wmem_max = 1073741824 +net.core.rmem_default = 16777216 +net.core.wmem_default = 16777216 +net.ipv4.tcp_rmem = 4096 16777216 1073741824 +net.ipv4.tcp_wmem = 4096 16777216 1073741824 +net.ipv4.tcp_mem = 4194304 8388608 16777216 +net.ipv4.udp_rmem_min = 16777216 +net.ipv4.udp_wmem_min = 16777216 + +# Enable TCP window scaling. +net.ipv4.tcp_window_scaling = 1 + +# Increase the length of the processor input queue +net.core.netdev_max_backlog = 100000 +net.core.netdev_budget = 1000 +net.core.netdev_budget_usecs = 10000 + +# Set better congestion control. +net.ipv4.tcp_congestion_control = htcp + +# Turn off fancy stuff for more stability. +net.ipv4.tcp_sack = 0 +net.ipv4.tcp_dsack = 0 +net.ipv4.tcp_fack = 0 +net.ipv4.tcp_timestamps = 0 + +# Max reorders before slow start. +net.ipv4.tcp_reordering = 3 + +# Prefer low latency to higher throughput. +# Disables IPv4 TCP prequeue processing. +net.ipv4.tcp_low_latency = 1 + +# Don't start slow. +net.ipv4.tcp_slow_start_after_idle = 0 diff --git a/spn/unit/doc.go b/spn/unit/doc.go new file mode 100644 index 00000000..9826a6ce --- /dev/null +++ b/spn/unit/doc.go @@ -0,0 +1,13 @@ +// Package unit provides a "work unit" scheduling system for handling data sets that traverse multiple workers / goroutines. +// The aim is to bind priority to a data set instead of a goroutine and split resources fairly among requests. +// +// Every "work" Unit is assigned an ever increasing ID and can be marked as "paused" or "high priority". +// The Scheduler always gives a clearance up to a certain ID. All units below this ID may be processed. +// High priority Units may always be processed. +// +// The Scheduler works with short slots and measures how many Units were finished in a slot. +// The "slot pace" holds an indication of the current Unit finishing speed per slot. It is only changed slowly (but boosts if too far away) in order to keep stabilize the system. +// The Scheduler then calculates the next unit ID limit to give clearance to for the next slot: +// +// "finished units" + "slot pace" + "paused units" - "fraction of high priority units" +package unit diff --git a/spn/unit/scheduler.go b/spn/unit/scheduler.go new file mode 100644 index 00000000..0b5d6e11 --- /dev/null +++ b/spn/unit/scheduler.go @@ -0,0 +1,358 @@ +package unit + +import ( + "context" + "errors" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/tevino/abool" +) + +const ( + defaultSlotDuration = 10 * time.Millisecond // 100 slots per second + defaultMinSlotPace = 100 // 10 000 pps + + defaultWorkSlotPercentage = 0.7 // 70% + defaultSlotChangeRatePerStreak = 0.02 // 2% + + defaultStatCycleDuration = 1 * time.Minute +) + +// Scheduler creates and schedules units. +// Must be created using NewScheduler(). +type Scheduler struct { //nolint:maligned + // Configuration. + config SchedulerConfig + + // Units IDs Limit / Thresholds. + + // currentUnitID holds the last assigned Unit ID. + currentUnitID atomic.Int64 + // clearanceUpTo holds the current threshold up to which Unit ID Units may be processed. + clearanceUpTo atomic.Int64 + // slotPace holds the current pace. This is the base value for clearance + // calculation, not the value of the current cleared Units itself. + slotPace atomic.Int64 + // finished holds the amount of units that were finished within the current slot. + finished atomic.Int64 + + // Slot management. + slotSignalA chan struct{} + slotSignalB chan struct{} + slotSignalSwitch bool + slotSignalsLock sync.RWMutex + + stopping abool.AtomicBool + unitDebugger *UnitDebugger + + // Stats. + stats struct { + // Working Values. + progress struct { + maxPace atomic.Int64 + maxLeveledPace atomic.Int64 + avgPaceSum atomic.Int64 + avgPaceCnt atomic.Int64 + avgUnitLifeSum atomic.Int64 + avgUnitLifeCnt atomic.Int64 + avgWorkSlotSum atomic.Int64 + avgWorkSlotCnt atomic.Int64 + avgCatchUpSlotSum atomic.Int64 + avgCatchUpSlotCnt atomic.Int64 + } + + // Calculated Values. + current struct { + maxPace atomic.Int64 + maxLeveledPace atomic.Int64 + avgPace atomic.Int64 + avgUnitLife atomic.Int64 + avgWorkSlot atomic.Int64 + avgCatchUpSlot atomic.Int64 + } + } +} + +// SchedulerConfig holds scheduler configuration. +type SchedulerConfig struct { + // SlotDuration defines the duration of one slot. + SlotDuration time.Duration + + // MinSlotPace defines the minimum slot pace. + // The slot pace will never fall below this value. + MinSlotPace int64 + + // WorkSlotPercentage defines the how much of a slot should be scheduled with work. + // The remainder is for catching up and breathing room for other tasks. + // Must be between 55% (0.55) and 95% (0.95). + // The default value is 0.7 (70%). + WorkSlotPercentage float64 + + // SlotChangeRatePerStreak defines how many percent (0-1) the slot pace + // should change per streak. + // Is enforced to be able to change the minimum slot pace by at least 1. + // The default value is 0.02 (2%). + SlotChangeRatePerStreak float64 + + // StatCycleDuration defines how often stats are calculated. + // The default value is 1 minute. + StatCycleDuration time.Duration +} + +// NewScheduler returns a new scheduler. +func NewScheduler(config *SchedulerConfig) *Scheduler { + // Fallback to empty config if none is given. + if config == nil { + config = &SchedulerConfig{} + } + + // Create new scheduler. + s := &Scheduler{ + config: *config, + slotSignalA: make(chan struct{}), + slotSignalB: make(chan struct{}), + } + + // Fill in defaults. + if s.config.SlotDuration == 0 { + s.config.SlotDuration = defaultSlotDuration + } + if s.config.MinSlotPace == 0 { + s.config.MinSlotPace = defaultMinSlotPace + } + if s.config.WorkSlotPercentage == 0 { + s.config.WorkSlotPercentage = defaultWorkSlotPercentage + } + if s.config.SlotChangeRatePerStreak == 0 { + s.config.SlotChangeRatePerStreak = defaultSlotChangeRatePerStreak + } + if s.config.StatCycleDuration == 0 { + s.config.StatCycleDuration = defaultStatCycleDuration + } + + // Check boundaries of WorkSlotPercentage. + switch { + case s.config.WorkSlotPercentage < 0.55: + s.config.WorkSlotPercentage = 0.55 + case s.config.WorkSlotPercentage > 0.95: + s.config.WorkSlotPercentage = 0.95 + } + + // The slot change rate must be able to change the slot pace by at least 1. + if s.config.SlotChangeRatePerStreak < (1 / float64(s.config.MinSlotPace)) { + s.config.SlotChangeRatePerStreak = (1 / float64(s.config.MinSlotPace)) + + // Debug logging: + // fmt.Printf("--- increased SlotChangeRatePerStreak to %f\n", s.config.SlotChangeRatePerStreak) + } + + // Initialize scheduler fields. + s.clearanceUpTo.Store(s.config.MinSlotPace) + s.slotPace.Store(s.config.MinSlotPace) + + return s +} + +func (s *Scheduler) nextSlotSignal() chan struct{} { + s.slotSignalsLock.RLock() + defer s.slotSignalsLock.RUnlock() + + if s.slotSignalSwitch { + return s.slotSignalA + } + return s.slotSignalB +} + +func (s *Scheduler) announceNextSlot() { + s.slotSignalsLock.Lock() + defer s.slotSignalsLock.Unlock() + + // Close new slot signal and refresh previous one. + if s.slotSignalSwitch { + close(s.slotSignalA) + s.slotSignalB = make(chan struct{}) + } else { + close(s.slotSignalB) + s.slotSignalA = make(chan struct{}) + } + + // Switch to next slot. + s.slotSignalSwitch = !s.slotSignalSwitch +} + +// SlotScheduler manages the slot and schedules units. +// Must only be started once. +func (s *Scheduler) SlotScheduler(ctx context.Context) error { + // Start slot ticker. + ticker := time.NewTicker(s.config.SlotDuration / 2) + defer ticker.Stop() + + // Give clearance to all when stopping. + defer s.clearanceUpTo.Store(math.MaxInt64 - math.MaxInt32) + + var ( + halfSlotID uint64 + halfSlotStartedAt = time.Now() + halfSlotEndedAt time.Time + halfSlotDuration = float64(s.config.SlotDuration / 2) + + increaseStreak float64 + decreaseStreak float64 + oneStreaks int + + cycleStatsAt = uint64(s.config.StatCycleDuration / (s.config.SlotDuration / 2)) + ) + + for range ticker.C { + halfSlotEndedAt = time.Now() + + switch { + case halfSlotID%2 == 0: + + // First Half-Slot: Work Slot + + // Calculate time taken in previous slot. + catchUpSlotDuration := halfSlotEndedAt.Sub(halfSlotStartedAt).Nanoseconds() + + // Add current slot duration to avg calculation. + s.stats.progress.avgCatchUpSlotCnt.Add(1) + if s.stats.progress.avgCatchUpSlotSum.Add(catchUpSlotDuration) < 0 { + // Reset if we wrap. + s.stats.progress.avgCatchUpSlotCnt.Store(1) + s.stats.progress.avgCatchUpSlotSum.Store(catchUpSlotDuration) + } + + // Reset slot counters. + s.finished.Store(0) + + // Raise clearance according + s.clearanceUpTo.Store( + s.currentUnitID.Load() + + int64( + float64(s.slotPace.Load())*s.config.WorkSlotPercentage, + ), + ) + + // Announce start of new slot. + s.announceNextSlot() + + default: + + // Second Half-Slot: Catch-Up Slot + + // Calculate time taken in previous slot. + workSlotDuration := halfSlotEndedAt.Sub(halfSlotStartedAt).Nanoseconds() + + // Add current slot duration to avg calculation. + s.stats.progress.avgWorkSlotCnt.Add(1) + if s.stats.progress.avgWorkSlotSum.Add(workSlotDuration) < 0 { + // Reset if we wrap. + s.stats.progress.avgWorkSlotCnt.Store(1) + s.stats.progress.avgWorkSlotSum.Store(workSlotDuration) + } + + // Calculate slot duration skew correction, as slots will not run in the + // exact specified duration. + slotDurationSkewCorrection := halfSlotDuration / float64(workSlotDuration) + + // Calculate slot pace with performance of first half-slot. + // Get current slot pace as float64. + currentSlotPace := float64(s.slotPace.Load()) + // Calculate current raw slot pace. + newRawSlotPace := float64(s.finished.Load()*2) * slotDurationSkewCorrection + + // Move slot pace in the trending direction. + if newRawSlotPace >= currentSlotPace { + // Adjust based on streak. + increaseStreak++ + decreaseStreak = 0 + s.slotPace.Add(int64( + currentSlotPace * s.config.SlotChangeRatePerStreak * increaseStreak, + )) + + // Count one-streaks. + if increaseStreak == 1 { + oneStreaks++ + } else { + oneStreaks = 0 + } + + // Debug logging: + // fmt.Printf("+++ slot pace: %.0f (current raw pace: %.0f, increaseStreak: %.0f, clearanceUpTo: %d)\n", currentSlotPace, newRawSlotPace, increaseStreak, s.clearanceUpTo.Load()) + } else { + // Adjust based on streak. + decreaseStreak++ + increaseStreak = 0 + s.slotPace.Add(int64( + -currentSlotPace * s.config.SlotChangeRatePerStreak * decreaseStreak, + )) + + // Enforce minimum. + if s.slotPace.Load() < s.config.MinSlotPace { + s.slotPace.Store(s.config.MinSlotPace) + decreaseStreak = 0 + } + + // Count one-streaks. + if decreaseStreak == 1 { + oneStreaks++ + } else { + oneStreaks = 0 + } + + // Debug logging: + // fmt.Printf("--- slot pace: %.0f (current raw pace: %.0f, decreaseStreak: %.0f, clearanceUpTo: %d)\n", currentSlotPace, newRawSlotPace, decreaseStreak, s.clearanceUpTo.Load()) + } + + // Record Stats + + // Add current pace to avg calculation. + s.stats.progress.avgPaceCnt.Add(1) + if s.stats.progress.avgPaceSum.Add(s.slotPace.Load()) < 0 { + // Reset if we wrap. + s.stats.progress.avgPaceCnt.Store(1) + s.stats.progress.avgPaceSum.Store(s.slotPace.Load()) + } + + // Check if current pace is new max. + if s.slotPace.Load() > s.stats.progress.maxPace.Load() { + s.stats.progress.maxPace.Store(s.slotPace.Load()) + } + + // Check if current pace is new leveled max + if oneStreaks >= 3 && s.slotPace.Load() > s.stats.progress.maxLeveledPace.Load() { + s.stats.progress.maxLeveledPace.Store(s.slotPace.Load()) + } + } + // Switch to other slot-half. + halfSlotID++ + halfSlotStartedAt = halfSlotEndedAt + + // Cycle stats after defined time period. + if halfSlotID%cycleStatsAt == 0 { + s.cycleStats() + } + + // Check if we are stopping. + select { + case <-ctx.Done(): + return nil + default: + } + if s.stopping.IsSet() { + return nil + } + } + + // We should never get here. + // If we do, trigger a worker restart via the service worker. + return errors.New("unexpected end of scheduler") +} + +// Stop stops the scheduler and gives clearance to all units. +func (s *Scheduler) Stop() { + s.stopping.Set() +} diff --git a/spn/unit/scheduler_stats.go b/spn/unit/scheduler_stats.go new file mode 100644 index 00000000..6fd1d272 --- /dev/null +++ b/spn/unit/scheduler_stats.go @@ -0,0 +1,87 @@ +package unit + +// Stats are somewhat racy, as one value of sum or count might already be +// updated with the latest slot data, while the other has been not. +// This is not so much of a problem, as slots are really short and the impact +// is very low. + +// cycleStats calculates the new values and cycles the current values. +func (s *Scheduler) cycleStats() { + // Get and reset max pace. + s.stats.current.maxPace.Store(s.stats.progress.maxPace.Load()) + s.stats.progress.maxPace.Store(0) + + // Get and reset max leveled pace. + s.stats.current.maxLeveledPace.Store(s.stats.progress.maxLeveledPace.Load()) + s.stats.progress.maxLeveledPace.Store(0) + + // Get and reset avg slot pace. + avgPaceCnt := s.stats.progress.avgPaceCnt.Load() + if avgPaceCnt > 0 { + s.stats.current.avgPace.Store(s.stats.progress.avgPaceSum.Load() / avgPaceCnt) + } else { + s.stats.current.avgPace.Store(0) + } + s.stats.progress.avgPaceCnt.Store(0) + s.stats.progress.avgPaceSum.Store(0) + + // Get and reset avg unit life. + avgUnitLifeCnt := s.stats.progress.avgUnitLifeCnt.Load() + if avgUnitLifeCnt > 0 { + s.stats.current.avgUnitLife.Store(s.stats.progress.avgUnitLifeSum.Load() / avgUnitLifeCnt) + } else { + s.stats.current.avgUnitLife.Store(0) + } + s.stats.progress.avgUnitLifeCnt.Store(0) + s.stats.progress.avgUnitLifeSum.Store(0) + + // Get and reset avg work slot duration. + avgWorkSlotCnt := s.stats.progress.avgWorkSlotCnt.Load() + if avgWorkSlotCnt > 0 { + s.stats.current.avgWorkSlot.Store(s.stats.progress.avgWorkSlotSum.Load() / avgWorkSlotCnt) + } else { + s.stats.current.avgWorkSlot.Store(0) + } + s.stats.progress.avgWorkSlotCnt.Store(0) + s.stats.progress.avgWorkSlotSum.Store(0) + + // Get and reset avg catch up slot duration. + avgCatchUpSlotCnt := s.stats.progress.avgCatchUpSlotCnt.Load() + if avgCatchUpSlotCnt > 0 { + s.stats.current.avgCatchUpSlot.Store(s.stats.progress.avgCatchUpSlotSum.Load() / avgCatchUpSlotCnt) + } else { + s.stats.current.avgCatchUpSlot.Store(0) + } + s.stats.progress.avgCatchUpSlotCnt.Store(0) + s.stats.progress.avgCatchUpSlotSum.Store(0) +} + +// GetMaxSlotPace returns the current maximum slot pace. +func (s *Scheduler) GetMaxSlotPace() int64 { + return s.stats.current.maxPace.Load() +} + +// GetMaxLeveledSlotPace returns the current maximum leveled slot pace. +func (s *Scheduler) GetMaxLeveledSlotPace() int64 { + return s.stats.current.maxLeveledPace.Load() +} + +// GetAvgSlotPace returns the current average slot pace. +func (s *Scheduler) GetAvgSlotPace() int64 { + return s.stats.current.avgPace.Load() +} + +// GetAvgUnitLife returns the current average unit lifetime until it is finished. +func (s *Scheduler) GetAvgUnitLife() int64 { + return s.stats.current.avgUnitLife.Load() +} + +// GetAvgWorkSlotDuration returns the current average work slot duration. +func (s *Scheduler) GetAvgWorkSlotDuration() int64 { + return s.stats.current.avgWorkSlot.Load() +} + +// GetAvgCatchUpSlotDuration returns the current average catch up slot duration. +func (s *Scheduler) GetAvgCatchUpSlotDuration() int64 { + return s.stats.current.avgCatchUpSlot.Load() +} diff --git a/spn/unit/scheduler_test.go b/spn/unit/scheduler_test.go new file mode 100644 index 00000000..3e3ec6ba --- /dev/null +++ b/spn/unit/scheduler_test.go @@ -0,0 +1,51 @@ +package unit + +import ( + "context" + "testing" +) + +func BenchmarkScheduler(b *testing.B) { + workers := 10 + + // Create and start scheduler. + s := NewScheduler(&SchedulerConfig{}) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + err := s.SlotScheduler(ctx) + if err != nil { + panic(err) + } + }() + defer cancel() + + // Init control structures. + done := make(chan struct{}) + finishedCh := make(chan struct{}) + + // Start workers. + for i := 0; i < workers; i++ { + go func() { + for { + u := s.NewUnit() + u.WaitForSlot() + u.Finish() + select { + case finishedCh <- struct{}{}: + case <-done: + return + } + } + }() + } + + // Start benchmark. + b.ResetTimer() + for i := 0; i < b.N; i++ { + <-finishedCh + } + b.StopTimer() + + // Cleanup. + close(done) +} diff --git a/spn/unit/unit.go b/spn/unit/unit.go new file mode 100644 index 00000000..d198fd64 --- /dev/null +++ b/spn/unit/unit.go @@ -0,0 +1,103 @@ +package unit + +import ( + "time" + + "github.com/tevino/abool" +) + +// Unit describes a "work unit" and is meant to be embedded into another struct +// used for passing data moving through multiple processing steps. +type Unit struct { + id int64 + scheduler *Scheduler + created time.Time + finished abool.AtomicBool + highPriority abool.AtomicBool +} + +// NewUnit returns a new unit within the scheduler. +func (s *Scheduler) NewUnit() *Unit { + return &Unit{ + id: s.currentUnitID.Add(1), + scheduler: s, + created: time.Now(), + } +} + +// ReUse re-initialized the unit to be able to reuse already allocated structs. +func (u *Unit) ReUse() { + // Finish previous unit. + u.Finish() + + // Get new ID and unset finish flag. + u.id = u.scheduler.currentUnitID.Add(1) + u.finished.UnSet() +} + +// WaitForSlot blocks until the unit may be processed. +func (u *Unit) WaitForSlot() { + // High priority units may always process. + if u.highPriority.IsSet() { + return + } + + for { + // Check if we are allowed to process in the current slot. + if u.id <= u.scheduler.clearanceUpTo.Load() { + return + } + + // Debug logging: + // fmt.Printf("unit %d waiting for clearance at %d\n", u.id, u.scheduler.clearanceUpTo.Load()) + + // Wait for next slot. + <-u.scheduler.nextSlotSignal() + } +} + +// Finish signals the unit scheduler that this unit has finished processing. +// Will no-op if called on a nil Unit. +func (u *Unit) Finish() { + if u == nil { + return + } + + // Always increase finished, even if the unit is from a previous epoch. + if u.finished.SetToIf(false, true) { + u.scheduler.finished.Add(1) + + // Record the time this unit took from creation to finish. + timeTaken := time.Since(u.created).Nanoseconds() + u.scheduler.stats.progress.avgUnitLifeCnt.Add(1) + if u.scheduler.stats.progress.avgUnitLifeSum.Add(timeTaken) < 0 { + // Reset if we wrap. + u.scheduler.stats.progress.avgUnitLifeCnt.Store(1) + u.scheduler.stats.progress.avgUnitLifeSum.Store(timeTaken) + } + } +} + +// MakeHighPriority marks the unit as high priority. +func (u *Unit) MakeHighPriority() { + switch { + case u.finished.IsSet(): + // Unit is already finished. + case !u.highPriority.SetToIf(false, true): + // Unit is already set to high priority. + // Else: High Priority set. + case u.id > u.scheduler.clearanceUpTo.Load(): + // Unit is outside current clearance, reduce clearance by one. + u.scheduler.clearanceUpTo.Add(-1) + } +} + +// IsHighPriority returns whether the unit has high priority. +func (u *Unit) IsHighPriority() bool { + return u.highPriority.IsSet() +} + +// RemovePriority removes the high priority mark. +func (u *Unit) RemovePriority() { + u.highPriority.UnSet() +} diff --git a/spn/unit/unit_debug.go b/spn/unit/unit_debug.go new file mode 100644 index 00000000..0ba053bd --- /dev/null +++ b/spn/unit/unit_debug.go @@ -0,0 +1,86 @@ +package unit + +import ( + "sync" + "time" + + "github.com/safing/portbase/log" +) + +// UnitDebugger is used to debug unit leaks. +type UnitDebugger struct { //nolint:golint + units map[int64]*UnitDebugData + unitsLock sync.Mutex +} + +// UnitDebugData represents a unit that is being debugged. +type UnitDebugData struct { //nolint:golint + unit *Unit + unitSource string +} + +// DebugUnit registers the given unit for debug output with the given source. +// Additional calls on the same unit update the unit source. +// StartDebugLog() must be called before calling DebugUnit(). +func (s *Scheduler) DebugUnit(u *Unit, unitSource string) { + // Check if scheduler and unit debugger are created. + if s == nil || s.unitDebugger == nil { + return + } + + s.unitDebugger.unitsLock.Lock() + defer s.unitDebugger.unitsLock.Unlock() + + s.unitDebugger.units[u.id] = &UnitDebugData{ + unit: u, + unitSource: unitSource, + } +} + +// StartDebugLog logs the scheduler state every second. +func (s *Scheduler) StartDebugLog() { + s.unitDebugger = &UnitDebugger{ + units: make(map[int64]*UnitDebugData), + } + + // Force StatCycleDuration to match the debug log output. + s.config.StatCycleDuration = time.Second + + go func() { + for { + s.debugStep() + time.Sleep(time.Second) + } + }() +} + +func (s *Scheduler) debugStep() { + s.unitDebugger.unitsLock.Lock() + defer s.unitDebugger.unitsLock.Unlock() + + // Go through debugging units and clear finished ones, count sources. + sources := make(map[string]int) + for id, debugUnit := range s.unitDebugger.units { + if debugUnit.unit.finished.IsSet() { + delete(s.unitDebugger.units, id) + } else { + cnt := sources[debugUnit.unitSource] + sources[debugUnit.unitSource] = cnt + 1 + } + } + + // Print current state. + log.Debugf( + `scheduler: state: slotPace=%d avgPace=%d maxPace=%d maxLeveledPace=%d currentUnitID=%d clearanceUpTo=%d unitLife=%s slotDurations=%s/%s`, + s.slotPace.Load(), + s.GetAvgSlotPace(), + s.GetMaxSlotPace(), + s.GetMaxLeveledSlotPace(), + s.currentUnitID.Load(), + s.clearanceUpTo.Load(), + time.Duration(s.GetAvgUnitLife()).Round(10*time.Microsecond), + time.Duration(s.GetAvgWorkSlotDuration()).Round(10*time.Microsecond), + time.Duration(s.GetAvgCatchUpSlotDuration()).Round(10*time.Microsecond), + ) + log.Debugf("scheduler: unit sources: %+v", sources) +} diff --git a/spn/unit/unit_test.go b/spn/unit/unit_test.go new file mode 100644 index 00000000..8f5a5ac8 --- /dev/null +++ b/spn/unit/unit_test.go @@ -0,0 +1,104 @@ +package unit + +import ( + "context" + "fmt" + "math" + "math/rand" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestUnit(t *testing.T) { //nolint:paralleltest + // Ignore deprectation, as the given alternative is not safe for concurrent use. + // The global rand methods use a locked seed, which is not available from outside. + rand.Seed(time.Now().UnixNano()) //nolint + + size := 1000000 + workers := 100 + + // Create and start scheduler. + s := NewScheduler(&SchedulerConfig{}) + s.StartDebugLog() + ctx, cancel := context.WithCancel(context.Background()) + go func() { + err := s.SlotScheduler(ctx) + if err != nil { + panic(err) + } + }() + defer cancel() + + // Create 10 workers. + var wg sync.WaitGroup + wg.Add(workers) + sizePerWorker := size / workers + for i := 0; i < workers; i++ { + go func() { + for i := 0; i < sizePerWorker; i++ { + u := s.NewUnit() + + // Make 1% high priority. + if rand.Int()%100 == 0 { //nolint:gosec // This is a test. + u.MakeHighPriority() + } + + u.WaitForSlot() + time.Sleep(10 * time.Microsecond) + u.Finish() + } + wg.Done() + }() + } + + // Wait for workers to finish. + wg.Wait() + + // Wait for two slot durations for values to update. + time.Sleep(s.config.SlotDuration * 2) + + // Print current state. + s.cycleStats() + fmt.Printf(`scheduler state: + currentUnitID = %d + slotPace = %d + clearanceUpTo = %d + finished = %d + maxPace = %d + maxLeveledPace = %d + avgPace = %d + avgUnitLife = %s + avgWorkSlot = %s + avgCatchUpSlot = %s +`, + s.currentUnitID.Load(), + s.slotPace.Load(), + s.clearanceUpTo.Load(), + s.finished.Load(), + s.GetMaxSlotPace(), + s.GetMaxLeveledSlotPace(), + s.GetAvgSlotPace(), + time.Duration(s.GetAvgUnitLife()), + time.Duration(s.GetAvgWorkSlotDuration()), + time.Duration(s.GetAvgCatchUpSlotDuration()), + ) + + // Check if everything seems good. + assert.Equal(t, size, int(s.currentUnitID.Load()), "currentUnitID must match size") + assert.GreaterOrEqual( + t, + int(s.clearanceUpTo.Load()), + size+int(float64(s.config.MinSlotPace)*s.config.SlotChangeRatePerStreak), + "clearanceUpTo must be at least size+minSlotPace", + ) + + // Shutdown + cancel() + time.Sleep(s.config.SlotDuration * 10) + + // Check if scheduler shut down correctly. + assert.Equal(t, math.MaxInt64-math.MaxInt32, int(s.clearanceUpTo.Load()), "clearance must be near MaxInt64") +}